feat: comprehensive model management enhancements and platform fixes

- Block 1: Refactored path logic into a centralized PathResolver for OS-agnostic and relative path handling.
- Block 2: Improved platform compatibility (shutdown, macOS menu, System Tray fallback).
- Block 3: Added Power-User features (Model Updater, Smart Cleanup, Thumbnails, Node Sync, Remote Status).
- Fixes: Recursive subfolder scanning, archive detection logic, and UI progress throttling.
This commit is contained in:
Thomas
2026-05-02 19:53:49 +02:00
parent b5753154d1
commit 5c6f37741f
54 changed files with 4828 additions and 531 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
package de.tki.comfymodels.domain;
public enum ModelFolder {
CHECKPOINTS("checkpoints"),
LORAS("loras"),
VAE("vae"),
CLIP("clip"),
CONTROLNET("controlnet"),
UPSCALE_MODELS("upscale_models"),
VAEECODE("vae_approx"),
UNET("unet"),
CLIP_VISION("clip_vision"),
STYLE_MODELS("style_models"),
HYPERNETWORKS("hypernetworks"),
PHOTOMAKER("photomaker"),
IP_ADAPTER("ipadapter"),
ONNX("onnx"),
LLM("llm"),
DIFFUSION_MODELS("diffusion_models"),
GLIGEN("gligen"),
EMBEDDINGS("embeddings"),
AUDIO_ENCODERS("audio_encoders"),
DIFFUSERS("diffusers"),
CONFIGS("configs"),
MODEL_PATCHES("model_patches"),
LATENT_UPSCALE_MODELS("latent_upscale_models"),
ARCHIVE("archive");
private final String defaultFolderName;
ModelFolder(String defaultFolderName) {
this.defaultFolderName = defaultFolderName;
}
public String getDefaultFolderName() {
return defaultFolderName;
}
public static ModelFolder fromString(String text) {
if (text == null) return CHECKPOINTS;
for (ModelFolder b : ModelFolder.values()) {
if (b.defaultFolderName.equalsIgnoreCase(text) || b.name().equalsIgnoreCase(text)) {
return b;
}
}
// Handle common variations
String lower = text.toLowerCase();
if (lower.contains("checkpoint")) return CHECKPOINTS;
if (lower.contains("lora")) return LORAS;
if (lower.contains("vae")) return VAE;
if (lower.contains("controlnet")) return CONTROLNET;
if (lower.contains("embedding")) return EMBEDDINGS;
return CHECKPOINTS; // Default fallback
}
}
@@ -7,6 +7,7 @@ public class ModelInfo {
private String name;
private String url;
private String size = "Unknown";
private long byteSize = -1;
private String popularity = ""; // Download-Zahlen zur Sicherheit
private String base;
private String save_path;
@@ -30,6 +31,8 @@ public class ModelInfo {
public void setUrl(String url) { this.url = url; }
public String getSize() { return size; }
public void setSize(String size) { this.size = size; }
public long getByteSize() { return byteSize; }
public void setByteSize(long byteSize) { this.byteSize = byteSize; }
public String getPopularity() { return popularity; }
public void setPopularity(String popularity) { this.popularity = popularity; }
@@ -0,0 +1,19 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
import java.util.List;
/**
* Service for scanning and cleaning up unused models.
*/
public interface ICleanupService {
/**
* Identifies models that haven't been accessed for the given number of months.
*/
List<ModelInfo> findUnusedModels(int monthsThreshold);
/**
* Archives the specified models.
*/
void archiveModels(List<ModelInfo> models, java.util.function.BiConsumer<Integer, String> onStatusUpdate, Runnable onFinished);
}
@@ -10,4 +10,9 @@ public interface IDownloadManager {
void togglePause();
void stop();
boolean isPaused();
/**
* Returns a map of current statuses for each model in the queue.
*/
java.util.Map<Integer, String> getQueueStatus();
}
@@ -21,4 +21,9 @@ public interface IModelSearchService {
long getRemoteSize(String url);
String formatSize(long bytes);
/**
* Checks Civitai for a newer version of the given model.
*/
java.util.Optional<ModelInfo> checkForUpdate(ModelInfo current);
}
@@ -0,0 +1,20 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
import java.util.List;
import java.util.Optional;
/**
* Service for checking and performing model updates.
*/
public interface IModelUpdateService {
/**
* Checks if a newer version of the given model exists on CivitAI.
*/
Optional<ModelInfo> checkForUpdate(ModelInfo current);
/**
* Batch check for updates for a list of models.
*/
void checkAllForUpdates(List<ModelInfo> currentModels, java.util.function.BiConsumer<Integer, ModelInfo> onUpdateFound, Runnable onFinished);
}
@@ -0,0 +1,19 @@
package de.tki.comfymodels.service;
import java.util.List;
import java.util.Set;
/**
* Service for identifying and synchronizing missing custom nodes.
*/
public interface INodeSyncService {
/**
* Parses a workflow JSON and returns a set of required but missing custom nodes.
*/
Set<String> findMissingNodes(String workflowJson);
/**
* Attempts to find installation instructions or repositories for missing nodes.
*/
void resolveMissingNodes(Set<String> missingNodes, java.util.function.BiConsumer<String, String> onResolved, Runnable onFinished);
}
@@ -0,0 +1,13 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
/**
* Service for downloading or generating model thumbnails.
*/
public interface IThumbnailService {
/**
* Downloads a preview image for the given model and saves it as .preview.png.
*/
void downloadThumbnail(ModelInfo model, java.util.function.Consumer<Boolean> onResult);
}
@@ -0,0 +1,205 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.domain.ModelInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.LongConsumer;
import java.util.stream.Stream;
@Service
public class ArchiveService {
private final ConfigService configService;
private final PathResolver pathResolver;
@Autowired
public ArchiveService(ConfigService configService, PathResolver pathResolver) {
this.configService = configService;
this.pathResolver = pathResolver;
}
public String normalizeFolder(String folder) {
return pathResolver.stripRedundantPrefixes(folder);
}
public Map<String, List<ModelInfo>> getModelsGroupedByFolder() {
return scanDirectory(configService.getModelsPath(), true);
}
public Map<String, List<ModelInfo>> getArchivedModelsGroupedByFolder() {
return scanDirectory(configService.getArchivePath(), false);
}
private Map<String, List<ModelInfo>> scanDirectory(String pathStr, boolean excludeArchived) {
Map<String, List<ModelInfo>> grouped = new TreeMap<>();
if (pathStr == null || pathStr.isEmpty()) return grouped;
Path root = Paths.get(pathStr);
if (!Files.exists(root)) return grouped;
try (Stream<Path> walk = Files.walk(root)) {
walk.filter(Files::isRegularFile)
.filter(this::isSupportedModel)
.filter(p -> !excludeArchived || !isAlreadyArchived(p))
.forEach(p -> {
Path relative = root.relativize(p);
String folder = relative.getParent() != null ? relative.getParent().toString() : "root";
ModelInfo info = new ModelInfo();
info.setName(p.getFileName().toString());
info.setSave_path(folder);
info.setType(folder);
try {
long bytes = Files.size(p);
double mb = bytes / (1024.0 * 1024.0);
if (mb > 1024) {
info.setSize(String.format("%.2f GB", mb / 1024.0));
} else {
info.setSize(String.format("%.2f MB", mb));
}
} catch (IOException e) {
info.setSize("Unknown");
}
grouped.computeIfAbsent(folder, k -> new ArrayList<>()).add(info);
});
} catch (IOException e) {
e.printStackTrace();
}
return grouped;
}
private boolean isSupportedModel(Path p) {
String n = p.getFileName().toString().toLowerCase();
return n.endsWith(".safetensors") || n.endsWith(".sft") || n.endsWith(".ckpt") || n.endsWith(".pth") || n.endsWith(".pt") || n.endsWith(".bin");
}
private boolean isAlreadyArchived(Path p) {
String archivePathStr = configService.getArchivePath();
if (archivePathStr == null || archivePathStr.isEmpty()) return false;
Path archivePath = Paths.get(archivePathStr).toAbsolutePath();
return p.toAbsolutePath().startsWith(archivePath);
}
public void moveToArchiveWithProgress(String relativePath, LongConsumer progressUpdate) throws IOException {
String modelsPath = configService.getModelsPath();
String archivePath = configService.getArchivePath();
if (modelsPath == null || archivePath == null) throw new IOException("Paths not configured");
Path source = Paths.get(modelsPath, relativePath);
Path target = Paths.get(archivePath, relativePath);
if (!Files.exists(source)) throw new IOException("Source not found: " + source);
Files.createDirectories(target.getParent());
moveWithProgress(source, target, progressUpdate);
}
public void moveToArchive(String relativePath) throws IOException {
moveToArchiveWithProgress(relativePath, null);
}
public void moveToArchiveWithProgress(String folder, String filename, LongConsumer progressUpdate) throws IOException {
String normFolder = normalizeFolder(folder);
String relPath = "root".equals(normFolder) ? filename : Paths.get(normFolder, filename).toString();
moveToArchiveWithProgress(relPath, progressUpdate);
}
public void moveToArchive(String folder, String filename) throws IOException {
moveToArchiveWithProgress(folder, filename, null);
}
public boolean restoreFromArchiveWithProgress(String folder, String filename, LongConsumer progressUpdate) {
String modelsPathStr = configService.getModelsPath();
String archivePathStr = configService.getArchivePath();
if (modelsPathStr == null || archivePathStr == null) return false;
Path modelsPath = Paths.get(modelsPathStr);
Path archivePath = Paths.get(archivePathStr);
String normFolder = normalizeFolder(folder);
// Primary location with case-insensitive resolution
Path archivedFolder = "root".equals(normFolder) ? archivePath : pathResolver.resolveCaseInsensitiveRecursive(archivePath, normFolder);
Path archived = archivedFolder.resolve(filename);
Path targetFolder = "root".equals(normFolder) ? modelsPath : modelsPath.resolve(normFolder);
Path target = targetFolder.resolve(filename);
// Fallback: If not at primary, check root of archive
if (!Files.exists(archived)) {
Path rootArchived = archivePath.resolve(filename);
if (Files.exists(rootArchived)) {
archived = rootArchived;
} else {
// Secondary Fallback: Check if it exists ANYWHERE in the archive (shallow search)
try (Stream<Path> walk = Files.walk(archivePath, 2)) {
java.util.Optional<Path> found = walk.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().equalsIgnoreCase(filename))
.findFirst();
if (found.isPresent()) {
archived = found.get();
} else {
System.err.println("Restore failed: " + filename + " not found in archive (checked " + normFolder + " and root)");
return false;
}
} catch (IOException e) {
return false;
}
}
}
try {
if (Files.exists(archived) && Files.size(archived) > 0) {
Files.createDirectories(target.getParent());
moveWithProgress(archived, target, progressUpdate);
return true;
}
} catch (IOException e) {
System.err.println("Error moving file from archive: " + e.getMessage());
e.printStackTrace();
}
return false;
}
public boolean restoreFromArchive(String folder, String filename) {
return restoreFromArchiveWithProgress(folder, filename, null);
}
private void moveWithProgress(Path source, Path target, LongConsumer progressUpdate) throws IOException {
// Optimization: If on same file store, move is instant
if (Files.getFileStore(source).equals(Files.getFileStore(target.getParent()))) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
if (progressUpdate != null) progressUpdate.accept(Files.size(target));
return;
}
// Cross-drive move: Copy with progress, then delete
try (InputStream in = Files.newInputStream(source);
OutputStream out = Files.newOutputStream(target)) {
byte[] buffer = new byte[1024 * 64]; // 64KB buffer
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
if (progressUpdate != null) progressUpdate.accept((long) bytesRead);
}
}
Files.delete(source);
}
}
@@ -0,0 +1,107 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.ICleanupService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
public class CleanupService implements ICleanupService {
private final ConfigService configService;
private final ArchiveService archiveService;
@Autowired
public CleanupService(ConfigService configService, ArchiveService archiveService) {
this.configService = configService;
this.archiveService = archiveService;
}
@Override
public List<ModelInfo> findUnusedModels(int monthsThreshold) {
List<ModelInfo> unused = new ArrayList<>();
String modelsPathStr = configService.getModelsPath();
if (modelsPathStr == null || modelsPathStr.isEmpty()) return unused;
Path root = Paths.get(modelsPathStr);
if (!Files.exists(root)) return unused;
Instant threshold = Instant.now().minus(monthsThreshold * 30L, ChronoUnit.DAYS);
try (Stream<Path> walk = Files.walk(root)) {
List<Path> candidates = walk.filter(Files::isRegularFile)
.filter(this::isSupportedModel)
.collect(Collectors.toList());
for (Path p : candidates) {
BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class);
// On some systems lastAccessTime might not be available or updated,
// so we use the newer of lastAccess and lastModified.
Instant lastUsed = attrs.lastAccessTime().toInstant();
if (attrs.lastModifiedTime().toInstant().isAfter(lastUsed)) {
lastUsed = attrs.lastModifiedTime().toInstant();
}
if (lastUsed.isBefore(threshold)) {
ModelInfo info = createInfo(p, root, attrs);
unused.add(info);
}
}
} catch (IOException e) {
e.printStackTrace();
}
return unused;
}
@Override
public void archiveModels(List<ModelInfo> models, BiConsumer<Integer, String> onStatusUpdate, Runnable onFinished) {
new Thread(() -> {
for (int i = 0; i < models.size(); i++) {
ModelInfo model = models.get(i);
if (onStatusUpdate != null) onStatusUpdate.accept(i, "📦 Archiving: " + model.getName());
try {
String relPath = model.getSave_path() + "/" + model.getName();
archiveService.moveToArchiveWithProgress(relPath, null);
if (onStatusUpdate != null) onStatusUpdate.accept(i, "✅ Archived");
} catch (IOException e) {
if (onStatusUpdate != null) onStatusUpdate.accept(i, "❌ Error: " + e.getMessage());
}
}
if (onFinished != null) onFinished.run();
}).start();
}
private boolean isSupportedModel(Path p) {
String n = p.getFileName().toString().toLowerCase();
return n.endsWith(".safetensors") || n.endsWith(".ckpt") || n.endsWith(".pt") || n.endsWith(".pth") || n.endsWith(".bin");
}
private ModelInfo createInfo(Path p, Path root, BasicFileAttributes attrs) {
ModelInfo info = new ModelInfo();
info.setName(p.getFileName().toString());
Path rel = root.relativize(p);
info.setSave_path(rel.getParent() != null ? rel.getParent().toString().replace("\\", "/") : "root");
info.setType(info.getSave_path());
long bytes = attrs.size();
double mb = bytes / (1024.0 * 1024.0);
info.setSize(String.format("%.2f MB", mb));
info.setByteSize(bytes);
return info;
}
}
@@ -26,7 +26,7 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
private final Pattern FILE_PATTERN = Pattern.compile("([a-zA-Z0-9_\\-\\.\\/]+\\.(?:safetensors|sft|ckpt|pth|pt|bin|onnx|yaml))", Pattern.CASE_INSENSITIVE);
private final Pattern COMP_VAE_PATTERN = Pattern.compile("(vae|tokenizer|autoencoder|encoder|decoder)", Pattern.CASE_INSENSITIVE);
private final Pattern COMP_CLIP_PATTERN = Pattern.compile("(clip|text.encoder|t5|llama|gemma|mistral|qwen|bert|vit|embed)", Pattern.CASE_INSENSITIVE);
private final Pattern COMP_UNET_PATTERN = Pattern.compile("(unet|diffusion|transformer|dit|model|base|upscale|esrgan|resnet|sampling)", Pattern.CASE_INSENSITIVE);
private final Pattern COMP_UNET_PATTERN = Pattern.compile("(unet|diffusion|transformer|dit|model|upscale|esrgan|resnet|sampling)", Pattern.CASE_INSENSITIVE);
private final Pattern MD_LINK_PATTERN = Pattern.compile("\\[([^\\]]+)\\]\\((https?://[^\\)]+)\\)", Pattern.CASE_INSENSITIVE);
private final Pattern NAKED_URL_PATTERN = Pattern.compile("(https?://[a-zA-Z0-9\\-\\._\\/\\?\\=\\&\\%]+(?:\\.(?:safetensors|sft|ckpt|pth|pt|bin|onnx|yaml))(?:\\?[^\\s\\\"\\']*)?)", Pattern.CASE_INSENSITIVE);
@@ -55,11 +55,24 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
info.setUrl(match.getUrl());
info.setSave_path(match.getSave_path());
info.setSize(match.getSize());
info.setByteSize(match.getByteSize());
info.setPopularity("📂 MODEL LIST MATCH");
} else {
if (aiService != null) {
String popularity = null;
if (geminiService != null) {
try {
popularity = geminiService.analyzeModel(info.getName());
} catch (Exception ignored) {}
}
if (popularity == null && aiService != null) {
LocalAIService.Prediction prediction = aiService.predictProvider(info.getName());
info.setPopularity(prediction.getLabel());
popularity = prediction.getLabel();
}
if (popularity != null) {
info.setPopularity(popularity);
}
if (globalContext != null && !globalContext.isEmpty() && info.getPopularity() != null && info.getPopularity().contains("Community")) {
@@ -126,7 +139,7 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
if (m != null) {
String n = m.optString("name", m.optString("filename"));
if (n != null && !n.isEmpty()) {
String rawPath = m.optString("directory", m.optString("type", "checkpoints"));
String rawPath = m.optString("directory", m.optString("type", de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName()));
ModelInfo info = new ModelInfo(rawPath, n, m.optString("url", "MISSING"));
info.setSave_path(rawPath);
addModelInfo(res, info);
@@ -180,7 +193,7 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
String url = fixUrl(mdMatcher.group(2));
if (name.contains(".")) {
String type = hint != null ? inferTypeFromKey(hint) : null;
if ((type == null || type.equals("checkpoints")) && url.contains("/resolve/main/")) {
if ((type == null || type.equals(de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName())) && url.contains("/resolve/main/")) {
String sub = url.substring(url.indexOf("/resolve/main/") + 14);
if (sub.contains("/")) {
String folder = sub.substring(0, sub.indexOf("/")).toLowerCase();
@@ -190,7 +203,7 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
}
}
if (type == null) type = ctx;
addModelInfo(res, new ModelInfo(type != null ? type : "checkpoints", name, url));
addModelInfo(res, new ModelInfo(type != null ? type : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName(), name, url));
}
}
@@ -204,7 +217,7 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
if (name.length() > 3) {
String type = hint != null ? inferTypeFromKey(hint) : null;
if (type == null) type = ctx;
addModelInfo(res, new ModelInfo(type != null ? type : "checkpoints", name, url));
addModelInfo(res, new ModelInfo(type != null ? type : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName(), name, url));
}
}
@@ -225,63 +238,65 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
if (type == null) type = ctx;
// Generic refinement based on structural keywords
if (COMP_VAE_PATTERN.matcher(fLow).find() && (type == null || type.equals("checkpoints"))) type = "vae";
else if (COMP_CLIP_PATTERN.matcher(fLow).find() && (type == null || type.equals("checkpoints"))) type = "clip";
else if (COMP_UNET_PATTERN.matcher(fLow).find() && (type == null || type.equals("checkpoints") || (type.equals("clip") && !fLow.contains("clip_l") && !fLow.contains("clip_g")))) type = "unet";
if (COMP_VAE_PATTERN.matcher(fLow).find() && (type == null || type.equals(de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName()))) type = "vae";
else if (COMP_CLIP_PATTERN.matcher(fLow).find() && (type == null || type.equals(de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName()))) type = "clip";
else if (COMP_UNET_PATTERN.matcher(fLow).find() && (type == null || type.equals(de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName()) || (type.equals("clip") && !fLow.contains("clip_l") && !fLow.contains("clip_g")))) type = "unet";
addModelInfo(res, new ModelInfo(type != null ? type : "checkpoints", f, "MISSING"));
addModelInfo(res, new ModelInfo(type != null ? type : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName(), f, "MISSING"));
}
}
}
private String inferTypeFromNode(String nt) {
nt = nt.toLowerCase();
if (nt.contains("lora")) return "loras";
if (nt.contains("vae")) return "vae";
if (nt.contains("controlnet") || nt.contains("t2i_adapter")) return "controlnet";
if (nt.contains("upscale") || nt.contains("esrgan")) return "upscale_models";
if (nt.contains("latent_upscale")) return "latent_upscale_models";
if (nt.contains("clip_vision") || nt.contains("clipvision")) return "clip_vision";
if (nt.contains("clip") || nt.contains("text_encoder") || nt.contains("tokenizer")) return "clip";
if (nt.contains("diffusion_model")) return "diffusion_models";
if (nt.contains("unet") || nt.contains("diffusion")) return "unet";
if (nt.contains("gligen")) return "gligen";
if (nt.contains("embeddings") || nt.contains("textual_inversion")) return "embeddings";
if (nt.contains("hypernetwork")) return "hypernetworks";
if (nt.contains("photomaker")) return "photomaker";
if (nt.contains("style_model")) return "style_models";
if (nt.contains("audio") && nt.contains("encoder")) return "audio_encoders";
if (nt.contains("diffusers")) return "diffusers";
if (nt.contains("configs")) return "configs";
if (nt.contains("model_patch")) return "model_patches";
if (nt.contains("checkpoint")) return de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName();
if (nt.contains("lora")) return de.tki.comfymodels.domain.ModelFolder.LORAS.getDefaultFolderName();
if (nt.contains("vae")) return de.tki.comfymodels.domain.ModelFolder.VAE.getDefaultFolderName();
if (nt.contains("controlnet") || nt.contains("t2i_adapter")) return de.tki.comfymodels.domain.ModelFolder.CONTROLNET.getDefaultFolderName();
if (nt.contains("upscale") || nt.contains("esrgan")) return de.tki.comfymodels.domain.ModelFolder.UPSCALE_MODELS.getDefaultFolderName();
if (nt.contains("latent_upscale")) return de.tki.comfymodels.domain.ModelFolder.LATENT_UPSCALE_MODELS.getDefaultFolderName();
if (nt.contains("clip_vision") || nt.contains("clipvision")) return de.tki.comfymodels.domain.ModelFolder.CLIP_VISION.getDefaultFolderName();
if (nt.contains("clip") || nt.contains("text_encoder") || nt.contains("tokenizer")) return de.tki.comfymodels.domain.ModelFolder.CLIP.getDefaultFolderName();
if (nt.contains("diffusion_model")) return de.tki.comfymodels.domain.ModelFolder.DIFFUSION_MODELS.getDefaultFolderName();
if (nt.contains("unet") || nt.contains("diffusion")) return de.tki.comfymodels.domain.ModelFolder.UNET.getDefaultFolderName();
if (nt.contains("gligen")) return de.tki.comfymodels.domain.ModelFolder.GLIGEN.getDefaultFolderName();
if (nt.contains("embeddings") || nt.contains("textual_inversion")) return de.tki.comfymodels.domain.ModelFolder.EMBEDDINGS.getDefaultFolderName();
if (nt.contains("hypernetwork")) return de.tki.comfymodels.domain.ModelFolder.HYPERNETWORKS.getDefaultFolderName();
if (nt.contains("photomaker")) return de.tki.comfymodels.domain.ModelFolder.PHOTOMAKER.getDefaultFolderName();
if (nt.contains("style_model")) return de.tki.comfymodels.domain.ModelFolder.STYLE_MODELS.getDefaultFolderName();
if (nt.contains("audio") && nt.contains("encoder")) return de.tki.comfymodels.domain.ModelFolder.AUDIO_ENCODERS.getDefaultFolderName();
if (nt.contains("diffusers")) return de.tki.comfymodels.domain.ModelFolder.DIFFUSERS.getDefaultFolderName();
if (nt.contains("configs")) return de.tki.comfymodels.domain.ModelFolder.CONFIGS.getDefaultFolderName();
if (nt.contains("model_patch")) return de.tki.comfymodels.domain.ModelFolder.MODEL_PATCHES.getDefaultFolderName();
return null;
}
private String inferTypeFromKey(String k) {
k = k.toLowerCase();
if (k.contains("lora")) return "loras";
if (k.contains("vae")) return "vae";
if (k.contains("clip_vision") || k.contains("clipvision")) return "clip_vision";
if (k.contains("clip") || k.contains("text_encoder") || k.contains("tokenizer")) return "clip";
if (k.contains("diffusion_model")) return "diffusion_models";
if (k.contains("unet") || k.contains("diffusion")) return "unet";
if (k.contains("control") && k.contains("net")) return "controlnet";
if (k.contains("upscale") || k.contains("esrgan")) return "upscale_models";
if (k.contains("embedding")) return "embeddings";
if (k.contains("hypernetwork")) return "hypernetworks";
if (k.contains("gligen")) return "gligen";
if (k.contains("photomaker")) return "photomaker";
if (k.contains("style_model")) return "style_models";
if (k.contains("audio") && k.contains("encoder")) return "audio_encoders";
if (k.contains("config")) return "configs";
if (k.contains("checkpoint")) return de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName();
if (k.contains("lora")) return de.tki.comfymodels.domain.ModelFolder.LORAS.getDefaultFolderName();
if (k.contains("vae")) return de.tki.comfymodels.domain.ModelFolder.VAE.getDefaultFolderName();
if (k.contains("clip_vision") || k.contains("clipvision")) return de.tki.comfymodels.domain.ModelFolder.CLIP_VISION.getDefaultFolderName();
if (k.contains("clip") || k.contains("text_encoder") || k.contains("tokenizer")) return de.tki.comfymodels.domain.ModelFolder.CLIP.getDefaultFolderName();
if (k.contains("diffusion_model")) return de.tki.comfymodels.domain.ModelFolder.DIFFUSION_MODELS.getDefaultFolderName();
if (k.contains("unet") || k.contains("diffusion")) return de.tki.comfymodels.domain.ModelFolder.UNET.getDefaultFolderName();
if (k.contains("control") && k.contains("net")) return de.tki.comfymodels.domain.ModelFolder.CONTROLNET.getDefaultFolderName();
if (k.contains("upscale") || k.contains("esrgan")) return de.tki.comfymodels.domain.ModelFolder.UPSCALE_MODELS.getDefaultFolderName();
if (k.contains("embedding")) return de.tki.comfymodels.domain.ModelFolder.EMBEDDINGS.getDefaultFolderName();
if (k.contains("hypernetwork")) return de.tki.comfymodels.domain.ModelFolder.HYPERNETWORKS.getDefaultFolderName();
if (k.contains("gligen")) return de.tki.comfymodels.domain.ModelFolder.GLIGEN.getDefaultFolderName();
if (k.contains("photomaker")) return de.tki.comfymodels.domain.ModelFolder.PHOTOMAKER.getDefaultFolderName();
if (k.contains("style_model")) return de.tki.comfymodels.domain.ModelFolder.STYLE_MODELS.getDefaultFolderName();
if (k.contains("audio") && k.contains("encoder")) return de.tki.comfymodels.domain.ModelFolder.AUDIO_ENCODERS.getDefaultFolderName();
if (k.contains("config")) return de.tki.comfymodels.domain.ModelFolder.CONFIGS.getDefaultFolderName();
return null;
}
private void addModelInfo(List<ModelInfo> res, ModelInfo info) {
for (ModelInfo e : res) {
if (e.getName().equalsIgnoreCase(info.getName())) {
// If the existing entry is generic "checkpoints" but we found a more specific type
if ("checkpoints".equals(e.getType()) && !"checkpoints".equals(info.getType())) {
// If the existing entry is generic de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName() but we found a more specific type
if (de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName().equals(e.getType()) && !de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName().equals(info.getType())) {
e.setType(info.getType());
if (info.getSave_path() != null) e.setSave_path(info.getSave_path());
}
@@ -290,7 +305,7 @@ public class ComfyModelAnalyzer implements IModelAnalyzer {
if (e.getUrl().equals("MISSING") && !info.getUrl().equals("MISSING")) {
e.setUrl(info.getUrl());
if (info.getSave_path() != null) e.setSave_path(info.getSave_path());
if (info.getType() != null && !"checkpoints".equals(info.getType())) e.setType(info.getType());
if (info.getType() != null && !de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName().equals(info.getType())) e.setType(info.getType());
}
return;
}
@@ -7,6 +7,8 @@ import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Service
public class ConfigService {
@@ -14,12 +16,20 @@ public class ConfigService {
private final String VAULT_FILE = "settings.vault";
private JSONObject settings = new JSONObject();
private String masterPassword = null;
private boolean vaultFresh = false;
private final EncryptionUtils encryptionUtils;
private final PathResolver pathResolver;
@Autowired
public ConfigService(EncryptionUtils encryptionUtils) {
public ConfigService(EncryptionUtils encryptionUtils, PathResolver pathResolver) {
this.encryptionUtils = encryptionUtils;
this.pathResolver = pathResolver;
}
@jakarta.annotation.PostConstruct
public void init() {
pathResolver.setComfyUIRoot(getComfyUIPath());
}
/**
@@ -64,11 +74,16 @@ public class ConfigService {
}
} else {
System.out.println("No vault found, initialized new empty vault at: " + vault.getAbsolutePath());
this.vaultFresh = true;
save();
}
}
}
public boolean isVaultFresh() {
return vaultFresh;
}
public void save() {
if (masterPassword == null) {
System.err.println("Cannot save: Vault not unlocked.");
@@ -90,9 +105,19 @@ public class ConfigService {
public String getHfToken() { return settings.optString("hf_token", ""); }
public void setHfToken(String token) { settings.put("hf_token", token); save(); }
public String getModelsPath() { return settings.optString("models_path", "C:\\pinokio\\api\\comfy.git\\app\\models"); }
public String getModelsPath() {
String path = settings.optString("models_path", PathResolver.MODELS_DIR);
return pathResolver.resolveModelsPath(path).toString();
}
public void setModelsPath(String path) { settings.put("models_path", path); save(); }
public String getArchivePath() {
String path = settings.optString("archive_path", PathResolver.ARCHIVE_DIR);
Path modelsPath = Paths.get(getModelsPath());
return pathResolver.resolveArchivePath(modelsPath, path).toString();
}
public void setArchivePath(String path) { settings.put("archive_path", path); save(); }
public boolean isBackgroundModeEnabled() { return settings.optBoolean("background_mode", false); }
public void setBackgroundModeEnabled(boolean enabled) { settings.put("background_mode", enabled); save(); }
@@ -123,7 +148,11 @@ public class ConfigService {
}
public String getComfyUIPath() { return settings.optString("comfyui_path", ""); }
public void setComfyUIPath(String path) { settings.put("comfyui_path", path); save(); }
public void setComfyUIPath(String path) {
settings.put("comfyui_path", path);
pathResolver.setComfyUIRoot(path);
save();
}
public String getApiToken() {
String token = settings.optString("api_token", "");
@@ -138,4 +167,19 @@ public class ConfigService {
public boolean isUnlocked() {
return masterPassword != null;
}
public boolean hasVault() {
return getFileInAppData(VAULT_FILE).exists();
}
public void resetVault() {
File vault = getFileInAppData(VAULT_FILE);
if (vault.exists()) {
vault.delete();
}
this.settings = new JSONObject();
this.masterPassword = null;
this.vaultFresh = true;
System.out.println("Vault has been reset.");
}
}
@@ -24,11 +24,16 @@ public class DefaultDownloadManager implements IDownloadManager {
private volatile boolean isStopped = false;
private volatile boolean[] currentSelection;
private final java.util.Set<Integer> completedIndices = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final java.util.Map<Integer, String> statusMap = new java.util.concurrent.ConcurrentHashMap<>();
@Autowired
private ConfigService configService;
@Autowired
private PathResolver pathResolver;
private void safeUpdateStatus(int index, String status, BiConsumer<Integer, String> statusUpdater) {
statusMap.put(index, status);
// Only mark as completed if it's a final state
boolean isFinal = status.contains("✅ Finished") ||
status.contains("Stopped") ||
@@ -42,12 +47,18 @@ public class DefaultDownloadManager implements IDownloadManager {
statusUpdater.accept(index, status);
}
@Override
public java.util.Map<Integer, String> getQueueStatus() {
return new java.util.HashMap<>(statusMap);
}
@Override
public void startQueue(List<ModelInfo> models, boolean[] selectedIndices, String baseDir, BiConsumer<Integer, String> statusUpdater, Runnable onFinished) {
isStopped = false;
isPaused = false;
this.currentSelection = selectedIndices;
completedIndices.clear();
statusMap.clear();
java.util.concurrent.atomic.AtomicBoolean finishedCalled = new java.util.concurrent.atomic.AtomicBoolean(false);
@@ -65,7 +76,6 @@ public class DefaultDownloadManager implements IDownloadManager {
}
if (!isSelected(index)) {
safeUpdateStatus(index, "Skipped (Not Selected)", statusUpdater);
futures[index] = java.util.concurrent.CompletableFuture.completedFuture(null);
continue;
}
@@ -77,11 +87,13 @@ public class DefaultDownloadManager implements IDownloadManager {
try {
String subPath = info.getSave_path();
if (subPath == null || subPath.isEmpty() || "default".equalsIgnoreCase(subPath)) {
subPath = info.getType() != null ? info.getType() : "checkpoints";
subPath = de.tki.comfymodels.domain.ModelFolder.fromString(info.getType()).getDefaultFolderName();
}
String finalBaseDir = baseDir != null ? baseDir : ".";
Path targetDir = Paths.get(finalBaseDir, subPath);
Path modelsBase = (baseDir != null && !baseDir.isEmpty()) ?
pathResolver.resolve(baseDir) :
pathResolver.resolve(configService.getModelsPath());
Path targetDir = modelsBase.resolve(subPath);
Files.createDirectories(targetDir);
downloadWithResume(info, targetDir.resolve(info.getName()), index, statusUpdater);
} catch (Exception e) {
@@ -124,6 +136,10 @@ public class DefaultDownloadManager implements IDownloadManager {
if (waitForPauseAndCheckSelection(index, statusUpdater)) return;
File file = targetFile.toFile();
String archivePath = configService.getArchivePath();
boolean isActuallyInArchive = archivePath != null && !archivePath.isEmpty() &&
file.getAbsolutePath().toLowerCase().startsWith(new File(archivePath).getAbsolutePath().toLowerCase());
String hfToken = configService.getHfToken();
// Check disk space before starting
@@ -134,11 +150,12 @@ public class DefaultDownloadManager implements IDownloadManager {
}
} catch (Exception ignored) {}
if (file.exists() && file.length() < 10240) {
file.delete();
if (file.exists() && (file.length() < 10240 || isActuallyInArchive)) {
// If it's in the archive, we don't treat it as "local existing model" for the download manager.
if (!isActuallyInArchive) file.delete();
}
long existingFileSize = file.exists() ? file.length() : 0;
long existingFileSize = (file.exists() && !isActuallyInArchive) ? file.length() : 0;
if (waitForPauseAndCheckSelection(index, statusUpdater)) return;
@@ -0,0 +1,181 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.domain.ModelInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Service for scanning local directories for ComfyUI models.
*/
@Service
public class LocalModelScanner {
private final ConfigService configService;
private final Map<String, Path> modelCache = new HashMap<>();
@Autowired
public LocalModelScanner(ConfigService configService) {
this.configService = configService;
}
/**
* Scans the configured models directory for supported model files.
*
* @return A list of ModelInfo objects representing the found local models.
*/
public List<ModelInfo> scanLocalModels() {
List<ModelInfo> foundModels = new ArrayList<>();
String modelsPathStr = configService.getModelsPath();
if (modelsPathStr == null || modelsPathStr.isEmpty()) {
return foundModels;
}
Path rootPath = Paths.get(modelsPathStr);
if (!Files.exists(rootPath) || !Files.isDirectory(rootPath)) {
return foundModels;
}
modelCache.clear();
try (Stream<Path> walk = Files.walk(rootPath)) {
List<Path> files = walk
.filter(Files::isRegularFile)
.filter(this::isSupportedModelFile)
.filter(p -> !isArchived(p, rootPath))
.collect(Collectors.toList());
for (Path file : files) {
modelCache.put(file.getFileName().toString().toLowerCase(), file);
foundModels.add(createModelInfo(file, rootPath));
}
} catch (IOException e) {
System.err.println("Error scanning local models: " + e.getMessage());
}
return foundModels;
}
/**
* Finds a local model by its filename, regardless of subfolder.
*/
public Optional<Path> findModel(String filename) {
String modelsPathStr = configService.getModelsPath();
if (modelsPathStr == null || modelsPathStr.isEmpty()) return Optional.empty();
return findModelInDirectory(Paths.get(modelsPathStr), filename);
}
/**
* Finds a model by its filename recursively starting from the given root directory.
*/
public Optional<Path> findModelInDirectory(Path root, String filename) {
return findModelWithPrefSize(root, filename, -1);
}
/**
* Finds a model by its filename recursively, prioritizing matches with the preferred size.
*/
public Optional<Path> findModelWithPrefSize(Path root, String filename, long preferredSize) {
if (filename == null || root == null || !Files.exists(root)) return Optional.empty();
try (Stream<Path> walk = Files.walk(root)) {
List<Path> matches = walk.filter(Files::isRegularFile)
.filter(p -> !isArchived(p, root))
.filter(p -> p.getFileName().toString().equalsIgnoreCase(filename))
.collect(Collectors.toList());
if (matches.isEmpty()) return Optional.empty();
// 1. Try to find a match with exact size if preferredSize is known
if (preferredSize > 0) {
for (Path p : matches) {
try {
if (Files.size(p) == preferredSize) return Optional.of(p);
} catch (IOException ignored) {}
}
}
// 2. Return first match if no size match found or size unknown
return Optional.of(matches.get(0));
} catch (IOException e) {
System.err.println("Error searching in " + root + ": " + e.getMessage());
return Optional.empty();
}
}
private boolean isSupportedModelFile(Path path) {
String name = path.getFileName().toString().toLowerCase();
return name.endsWith(".safetensors") || name.endsWith(".ckpt") || name.endsWith(".pt");
}
private boolean isArchived(Path path, Path rootPath) {
// 1. If we are already searching inside an archive folder (e.g. E:\Archive),
// we should not filter out anything based on the "archive" name.
String rootName = rootPath.getFileName() != null ? rootPath.getFileName().toString().toLowerCase() : "";
if (rootName.contains("archive")) {
return false;
}
// 2. Also check if the path is inside the specifically configured archive directory
try {
String archivePathStr = configService.getArchivePath();
if (archivePathStr != null && !archivePathStr.isEmpty()) {
Path archivePath = Paths.get(archivePathStr).toAbsolutePath();
if (path.toAbsolutePath().startsWith(archivePath)) {
return true;
}
}
} catch (Exception ignored) {}
// 3. Fallback: ignore any subfolder named "archive"
int rootCount = rootPath.getNameCount();
for (int i = rootCount; i < path.getNameCount(); i++) {
if (path.getName(i).toString().equalsIgnoreCase("archive")) {
return true;
}
}
return false;
}
private ModelInfo createModelInfo(Path file, Path rootPath) {
String fileName = file.getFileName().toString();
Path relativePath = rootPath.relativize(file);
// Derive type from the first folder after root
String type = "unknown";
if (relativePath.getNameCount() > 1) {
type = relativePath.getName(0).toString();
}
ModelInfo info = new ModelInfo(type, fileName, "LOCAL");
// Set save_path to the relative parent path
if (relativePath.getParent() != null) {
info.setSave_path(relativePath.getParent().toString().replace("\\", "/"));
}
info.setFilename(fileName);
// Calculate size in MB
try {
long bytes = Files.size(file);
double mb = bytes / (1024.0 * 1024.0);
info.setSize(String.format("%.2f MB", mb));
} catch (IOException e) {
info.setSize("Unknown");
}
return info;
}
}
@@ -29,6 +29,10 @@ public class ModelListService {
public void importJson(File file) throws Exception {
String content = Files.readString(file.toPath(), StandardCharsets.UTF_8);
importJsonContent(content);
}
public void importJsonContent(String content) throws Exception {
JSONObject root = new JSONObject(content);
JSONArray modelsArray = root.getJSONArray("models");
@@ -46,7 +50,9 @@ public class ModelListService {
info.setDescription(m.optString("description", ""));
info.setReference(m.optString("reference", ""));
info.setFilename(m.optString("filename", ""));
info.setSize(m.optString("size", "Unknown"));
String sizeStr = m.optString("size", "Unknown");
info.setSize(sizeStr);
info.setByteSize(parseSize(sizeStr));
newModels.add(info);
}
@@ -54,6 +60,49 @@ public class ModelListService {
saveToStorage();
}
public void importFromUrl(String url) {
if (url == null || url.isEmpty()) return;
// Ensure we use the raw URL if a GitHub blob URL was provided
final String finalUrl = url.contains("github.com") && url.contains("/blob/") ?
url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") : url;
new Thread(() -> {
try {
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
.followRedirects(java.net.http.HttpClient.Redirect.ALWAYS)
.connectTimeout(java.time.Duration.ofSeconds(10))
.build();
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(finalUrl))
.build();
java.net.http.HttpResponse<String> response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
importJsonContent(response.body());
System.out.println("[Auto-Import] Successfully updated model list (" + models.size() + " models).");
}
} catch (Exception ignored) {
// Silent fail as requested - no stacktrace, no dialog
System.out.println("[Auto-Import] Optional model list update skipped (source unreachable or invalid).");
}
}).start();
}
private long parseSize(String sizeStr) {
if (sizeStr == null || sizeStr.isEmpty() || "Unknown".equals(sizeStr)) return -1;
try {
String clean = sizeStr.toUpperCase().replaceAll("[^0-9\\.]", "");
double val = Double.parseDouble(clean);
if (sizeStr.toUpperCase().contains("GB")) return (long) (val * 1024L * 1024L * 1024L);
if (sizeStr.toUpperCase().contains("MB")) return (long) (val * 1024L * 1024L);
if (sizeStr.toUpperCase().contains("KB")) return (long) (val * 1024L);
return (long) val;
} catch (Exception e) {
return -1;
}
}
public Optional<ModelInfo> findByFilename(String filename) {
if (filename == null) return Optional.empty();
String low = filename.toLowerCase();
@@ -99,7 +148,9 @@ public class ModelListService {
info.setDescription(m.optString("description"));
info.setReference(m.optString("reference"));
info.setFilename(m.optString("filename"));
info.setSize(m.optString("size"));
String sizeStr = m.optString("size", "Unknown");
info.setSize(sizeStr);
info.setByteSize(parseSize(sizeStr));
models.add(info);
}
}
@@ -113,7 +113,7 @@ public class ModelSearchService implements IModelSearchService {
// Priority 2: Civitai Hash Lookup
String modelsPath = configService.getModelsPath();
java.io.File localFile = new java.io.File(modelsPath, (info.getSave_path() != null ? info.getSave_path() : (info.getType() != null ? info.getType() : "checkpoints")) + java.io.File.separator + info.getName());
java.io.File localFile = new java.io.File(modelsPath, (info.getSave_path() != null ? info.getSave_path() : (info.getType() != null ? info.getType() : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName())) + java.io.File.separator + info.getName());
if (localFile.exists()) {
onStatusUpdate.accept(index, "🔍 Hashing for Civitai...");
String hash = hashRegistry.getOrCalculateHash(localFile);
@@ -163,6 +163,67 @@ public class ModelSearchService implements IModelSearchService {
onStatusUpdate.accept(index, "❌ No trusted match found");
}
@Override
public Optional<ModelInfo> checkForUpdate(ModelInfo current) {
String modelsPath = configService.getModelsPath();
String subPath = (current.getSave_path() != null ? current.getSave_path() : (current.getType() != null ? current.getType() : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName()));
java.io.File localFile = new java.io.File(modelsPath, subPath + java.io.File.separator + current.getName());
if (!localFile.exists()) {
return Optional.empty();
}
String hash = hashRegistry.getOrCalculateHash(localFile);
if (hash == null) return Optional.empty();
try {
String hashUrl = getCivitaiApiBaseUrl() + "/model-versions/by-hash/" + hash;
HttpResponse<String> hashRes = httpClient.send(HttpRequest.newBuilder().uri(URI.create(hashUrl)).build(), HttpResponse.BodyHandlers.ofString());
if (hashRes.statusCode() != 200) return Optional.empty();
JSONObject currentVersion = new JSONObject(hashRes.body());
int modelId = currentVersion.optInt("modelId", -1);
int currentVersionId = currentVersion.optInt("id", -1);
if (modelId == -1 || currentVersionId == -1) return Optional.empty();
String modelUrl = getCivitaiApiBaseUrl() + "/models/" + modelId;
HttpResponse<String> modelRes = httpClient.send(HttpRequest.newBuilder().uri(URI.create(modelUrl)).build(), HttpResponse.BodyHandlers.ofString());
if (modelRes.statusCode() != 200) return Optional.empty();
JSONObject modelData = new JSONObject(modelRes.body());
JSONArray versions = modelData.optJSONArray("modelVersions");
if (versions == null || versions.length() == 0) return Optional.empty();
JSONObject latestVersion = versions.getJSONObject(0);
int latestVersionId = latestVersion.optInt("id", -1);
if (latestVersionId != -1 && latestVersionId != currentVersionId) {
JSONArray files = latestVersion.optJSONArray("files");
if (files != null) {
for (int i = 0; i < files.length(); i++) {
JSONObject file = files.getJSONObject(i);
if (file.optBoolean("primary", false) || files.length() == 1) {
ModelInfo updated = new ModelInfo();
updated.setType(current.getType());
updated.setSave_path(current.getSave_path());
updated.setName(file.getString("name"));
updated.setUrl(file.getString("downloadUrl"));
long size = getRemoteSize(updated.getUrl());
updated.setByteSize(size);
updated.setSize(formatSize(size));
updated.setPopularity("⭐ UPDATE: " + latestVersion.optString("name", "New Version"));
return Optional.of(updated);
}
}
}
}
} catch (Exception e) {
// Fail silently
}
return Optional.empty();
}
private boolean validateAndSetUrl(ModelInfo info, int rowIndex, String url, String popPrefix,
BiConsumer<Integer, String> onStatusUpdate,
BiConsumer<Integer, ModelInfo> onModelFound) {
@@ -176,6 +237,7 @@ public class ModelSearchService implements IModelSearchService {
if (size > 100) {
info.setUrl(url);
info.setPopularity(popPrefix);
info.setByteSize(size);
info.setSize(formatSize(size));
onModelFound.accept(rowIndex, info);
return true;
@@ -0,0 +1,131 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.IModelUpdateService;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.File;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
@Service
public class ModelUpdateService implements IModelUpdateService {
private final ConfigService configService;
private final ModelHashRegistry hashRegistry;
private final HttpClient httpClient;
private final ExecutorService updateExecutor = Executors.newFixedThreadPool(4);
@Autowired
public ModelUpdateService(ConfigService configService, ModelHashRegistry hashRegistry) {
this.configService = configService;
this.hashRegistry = hashRegistry;
this.httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build();
}
protected String getCivitaiApiBaseUrl() {
return "https://civitai.com/api/v1";
}
@Override
public Optional<ModelInfo> checkForUpdate(ModelInfo current) {
String modelsPath = configService.getModelsPath();
String subPath = (current.getSave_path() != null ? current.getSave_path() : (current.getType() != null ? current.getType() : "checkpoints"));
File localFile = new File(modelsPath, subPath + File.separator + current.getName());
if (!localFile.exists()) {
return Optional.empty();
}
String hash = hashRegistry.getOrCalculateHash(localFile);
if (hash == null) return Optional.empty();
try {
String hashUrl = getCivitaiApiBaseUrl() + "/model-versions/by-hash/" + hash;
HttpResponse<String> hashRes = httpClient.send(HttpRequest.newBuilder().uri(URI.create(hashUrl)).build(), HttpResponse.BodyHandlers.ofString());
if (hashRes.statusCode() != 200) return Optional.empty();
JSONObject currentVersion = new JSONObject(hashRes.body());
int modelId = currentVersion.optInt("modelId", -1);
int currentVersionId = currentVersion.optInt("id", -1);
if (modelId == -1 || currentVersionId == -1) return Optional.empty();
String modelUrl = getCivitaiApiBaseUrl() + "/models/" + modelId;
HttpResponse<String> modelRes = httpClient.send(HttpRequest.newBuilder().uri(URI.create(modelUrl)).build(), HttpResponse.BodyHandlers.ofString());
if (modelRes.statusCode() != 200) return Optional.empty();
JSONObject modelData = new JSONObject(modelRes.body());
JSONArray versions = modelData.optJSONArray("modelVersions");
if (versions == null || versions.length() == 0) return Optional.empty();
// CivitAI usually returns versions in descending order of creation (newest first)
JSONObject latestVersion = versions.getJSONObject(0);
int latestVersionId = latestVersion.optInt("id", -1);
if (latestVersionId != -1 && latestVersionId != currentVersionId) {
JSONArray files = latestVersion.optJSONArray("files");
if (files != null) {
for (int i = 0; i < files.length(); i++) {
JSONObject file = files.getJSONObject(i);
// We prefer primary files, or the first one if only one exists
if (file.optBoolean("primary", false) || files.length() == 1) {
ModelInfo updated = new ModelInfo();
updated.setType(current.getType());
updated.setSave_path(current.getSave_path());
updated.setName(file.getString("name"));
updated.setUrl(file.getString("downloadUrl"));
updated.setPopularity("⭐ UPDATE: " + latestVersion.optString("name", "New Version"));
// Check if the updated file has a size
if (file.has("sizeKB")) {
long kb = file.getLong("sizeKB");
updated.setByteSize(kb * 1024);
updated.setSize(String.format("%.2f GB", kb / 1024.0 / 1024.0));
}
return Optional.of(updated);
}
}
}
}
} catch (Exception e) {
System.err.println("Error checking for update for " + current.getName() + ": " + e.getMessage());
}
return Optional.empty();
}
@Override
public void checkAllForUpdates(List<ModelInfo> currentModels, BiConsumer<Integer, ModelInfo> onUpdateFound, Runnable onFinished) {
if (currentModels == null || currentModels.isEmpty()) {
if (onFinished != null) onFinished.run();
return;
}
AtomicInteger remaining = new AtomicInteger(currentModels.size());
for (int i = 0; i < currentModels.size(); i++) {
final int index = i;
ModelInfo info = currentModels.get(i);
updateExecutor.submit(() -> {
try {
checkForUpdate(info).ifPresent(update -> onUpdateFound.accept(index, update));
} catch (Exception ignored) {
} finally {
if (remaining.decrementAndGet() == 0 && onFinished != null) {
onFinished.run();
}
}
});
}
}
}
@@ -0,0 +1,77 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.service.INodeSyncService;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.function.BiConsumer;
@Service
public class NodeSyncService implements INodeSyncService {
private final ConfigService configService;
// Minimal set of built-in nodes to avoid false positives
private static final Set<String> BUILTIN_NODES = new HashSet<>(Arrays.asList(
"KSampler", "CheckpointLoaderSimple", "CLIPTextEncode", "EmptyLatentImage",
"VAEDecode", "VAEEncode", "ControlNetApply", "CLIPVisionEncode",
"ImageUpscaleWithModel", "LoraLoader", "PreviewImage", "SaveImage",
"ConditioningAverage", "ConditioningCombine", "ConditioningConcat",
"LatentUpscale", "ModelSamplingDiscrete", "SetLatentNoiseMask"
// ... truncated for brevity, in a real app this would be much larger or loaded from a file
));
@Autowired
public NodeSyncService(ConfigService configService) {
this.configService = configService;
}
@Override
public Set<String> findMissingNodes(String workflowJson) {
Set<String> missing = new HashSet<>();
if (workflowJson == null || workflowJson.isEmpty()) return missing;
try {
JSONObject jo = new JSONObject(workflowJson);
// ComfyUI workflows can be in API format or UI format
if (jo.has("nodes")) {
// UI format
org.json.JSONArray nodes = jo.getJSONArray("nodes");
for (int i = 0; i < nodes.length(); i++) {
String type = nodes.getJSONObject(i).optString("type");
if (type != null && !type.isEmpty() && !BUILTIN_NODES.contains(type)) {
missing.add(type);
}
}
} else {
// API format
for (String key : jo.keySet()) {
JSONObject node = jo.optJSONObject(key);
if (node != null && node.has("class_type")) {
String type = node.getString("class_type");
if (!BUILTIN_NODES.contains(type)) {
missing.add(type);
}
}
}
}
} catch (Exception ignored) {}
return missing;
}
@Override
public void resolveMissingNodes(Set<String> missingNodes, BiConsumer<String, String> onResolved, Runnable onFinished) {
// In a real implementation, this might search ComfyUI-Manager's database or GitHub
new Thread(() -> {
for (String node : missingNodes) {
if (onResolved != null) {
onResolved.accept(node, "https://github.com/search?q=" + node + "+ComfyUI&type=repositories");
}
}
if (onFinished != null) onFinished.run();
}).start();
}
}
@@ -0,0 +1,182 @@
package de.tki.comfymodels.service.impl;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;
@Component
public class PathResolver {
public static final String MODELS_DIR = "models";
public static final String ARCHIVE_DIR = "archive";
public static final String CUSTOM_NODES_DIR = "custom_nodes";
private String comfyUIRoot;
public void setComfyUIRoot(String root) {
this.comfyUIRoot = root;
}
public String getComfyUIRoot() {
return comfyUIRoot;
}
/**
* Resolves a path. If it's absolute, returns it.
* If relative, resolves against ComfyUI root.
*/
public Path resolve(String inputPath) {
if (inputPath == null || inputPath.isEmpty()) return null;
Path p = Paths.get(inputPath);
if (p.isAbsolute()) return p;
if (comfyUIRoot == null || comfyUIRoot.isEmpty()) return p.toAbsolutePath();
return Paths.get(comfyUIRoot).resolve(inputPath);
}
/**
* Tries to find a file/folder case-insensitively within a parent directory.
*/
public Path resolveCaseInsensitive(Path parent, String childName) {
if (childName == null || childName.isEmpty() || childName.equals("root")) return parent;
if (!Files.exists(parent) || !Files.isDirectory(parent)) return parent.resolve(childName);
try (Stream<Path> stream = Files.list(parent)) {
return stream
.filter(p -> p.getFileName().toString().equalsIgnoreCase(childName))
.findFirst()
.orElse(parent.resolve(childName));
} catch (IOException e) {
return parent.resolve(childName);
}
}
/**
* Recursively resolves a path case-insensitively.
* E.g. "MODELS/CHECKPOINTS" -> "models/checkpoints"
*/
public Path resolveCaseInsensitiveRecursive(Path root, String relativePath) {
if (relativePath == null || relativePath.isEmpty()) return root;
Path current = root;
String normalizedPath = relativePath.replace("\\", "/");
String[] segments = normalizedPath.split("/");
for (String segment : segments) {
if (segment.isEmpty() || segment.equals(".")) continue;
current = resolveCaseInsensitive(current, segment);
}
return current;
}
/**
* Robust search for custom_nodes directory starting from a root path.
*/
public Path findCustomNodes(Path root) {
if (root == null) return null;
// 1. Direct check: Is the root itself the custom_nodes folder?
if (root.getFileName() != null && root.getFileName().toString().equalsIgnoreCase(CUSTOM_NODES_DIR)) {
return root;
}
// 2. Immediate candidates (common layouts)
String[] candidates = {
"resources/ComfyUI/" + CUSTOM_NODES_DIR,
"ComfyUI/" + CUSTOM_NODES_DIR,
CUSTOM_NODES_DIR
};
for (String c : candidates) {
Path p = root.resolve(c);
if (Files.exists(p) && Files.isDirectory(p)) return p;
}
// 3. Fallback: Search 3 levels deeper for non-standard structures
return findDeep(root, CUSTOM_NODES_DIR, 3);
}
/**
* Resolves the models path. Tries to find it case-insensitively if it's relative.
*/
public Path resolveModelsPath(String configPath) {
if (configPath == null || configPath.isEmpty()) configPath = MODELS_DIR;
Path p = Paths.get(configPath);
if (p.isAbsolute()) return p;
if (comfyUIRoot != null && !comfyUIRoot.isEmpty()) {
return resolveCaseInsensitiveRecursive(Paths.get(comfyUIRoot), configPath);
}
return p.toAbsolutePath();
}
/**
* Resolves the archive path. Usually relative to models path if not absolute.
*/
public Path resolveArchivePath(Path modelsPath, String configPath) {
if (configPath == null || configPath.isEmpty()) configPath = ARCHIVE_DIR;
Path p = Paths.get(configPath);
if (p.isAbsolute()) return p;
return resolveCaseInsensitiveRecursive(modelsPath, configPath);
}
/**
* Resolves a specific model folder (e.g. checkpoints) within the models directory.
*/
public Path resolveModelFolder(Path modelsPath, de.tki.comfymodels.domain.ModelFolder folder) {
return resolveCaseInsensitive(modelsPath, folder.getDefaultFolderName());
}
/**
* Resolves a specific model folder by string name.
*/
public Path resolveModelFolder(Path modelsPath, String folderName) {
return resolveCaseInsensitiveRecursive(modelsPath, stripRedundantPrefixes(folderName));
}
private Path findDeep(Path root, String targetName, int maxDepth) {
try (Stream<Path> stream = Files.walk(root, maxDepth)) {
return stream
.filter(p -> Files.isDirectory(p))
.filter(p -> p.getFileName() != null && p.getFileName().toString().equalsIgnoreCase(targetName))
.findFirst()
.orElse(null);
} catch (IOException e) {
return null;
}
}
/**
* Strips redundant prefixes like "models/" or "archive/" and returns a normalized relative path.
*/
public String stripRedundantPrefixes(String folder) {
if (folder == null || folder.isEmpty() || folder.equalsIgnoreCase("root")) return "root";
String normalized = folder.replace("\\", "/");
if (normalized.toLowerCase().startsWith(ARCHIVE_DIR + "/")) {
normalized = normalized.substring(ARCHIVE_DIR.length() + 1);
} else if (normalized.toLowerCase().equals(ARCHIVE_DIR)) {
return "root";
}
if (normalized.toLowerCase().startsWith(MODELS_DIR + "/")) {
normalized = normalized.substring(MODELS_DIR.length() + 1);
} else if (normalized.toLowerCase().equals(MODELS_DIR)) {
return "root";
}
if (normalized.isEmpty() || normalized.equals("/") || normalized.equalsIgnoreCase("root")) return "root";
return normalized;
}
}
@@ -3,10 +3,12 @@ package de.tki.comfymodels.service.impl;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import de.tki.comfymodels.domain.ModelInfo;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.io.InputStream;
@@ -18,6 +20,15 @@ import java.util.function.Consumer;
@Service
public class RestBridgeService {
@Autowired
private LocalModelScanner localScanner;
@Autowired
private ArchiveService archiveService;
@Autowired
private de.tki.comfymodels.service.IDownloadManager downloadManager;
private HttpServer server;
private Consumer<String> workflowConsumer;
private int port = 12345;
@@ -40,6 +51,9 @@ public class RestBridgeService {
try {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/import", new ImportHandler());
server.createContext("/api/models/local", new LocalModelsHandler());
server.createContext("/api/models/archive", new ArchiveHandler());
server.createContext("/api/status", new StatusHandler());
server.setExecutor(null);
server.start();
System.out.println("REST Bridge started on port " + port);
@@ -56,67 +70,138 @@ public class RestBridgeService {
}
}
private boolean handleSecurity(HttpExchange exchange, String method) throws IOException {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type, Authorization");
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return false;
}
String authHeader = exchange.getRequestHeaders().getFirst("Authorization");
if (expectedApiToken != null && !expectedApiToken.isEmpty()) {
String expected = "Bearer " + expectedApiToken.trim();
if (authHeader == null || !authHeader.trim().equals(expected)) {
System.err.println("REST Bridge: 401 Unauthorized request from " + exchange.getRemoteAddress());
if (authHeader == null) {
System.err.println(" Reason: Missing Authorization header");
} else {
// Log only start/end for security
String received = authHeader.trim();
System.err.println(" Reason: Token mismatch.");
System.err.println(" Expected start: " + expected.substring(0, Math.min(expected.length(), 15)) + "...");
System.err.println(" Received start: " + received.substring(0, Math.min(received.length(), 15)) + "...");
}
exchange.sendResponseHeaders(401, -1);
return false;
}
}
if (!method.equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return false;
}
return true;
}
private void sendJsonResponse(HttpExchange exchange, int code, String json) throws IOException {
byte[] responseBytes = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(code, responseBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(responseBytes);
}
}
private class StatusHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!handleSecurity(exchange, "GET")) return;
try {
java.util.Map<Integer, String> status = downloadManager.getQueueStatus();
JSONObject jo = new JSONObject();
JSONObject statuses = new JSONObject();
for (java.util.Map.Entry<Integer, String> entry : status.entrySet()) {
statuses.put(String.valueOf(entry.getKey()), entry.getValue());
}
jo.put("queue", statuses);
jo.put("paused", downloadManager.isPaused());
sendJsonResponse(exchange, 200, jo.toString());
} catch (Exception e) {
sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}");
} finally {
exchange.close();
}
}
}
private class LocalModelsHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!handleSecurity(exchange, "GET")) return;
try {
java.util.List<ModelInfo> localModels = localScanner.scanLocalModels();
JSONArray ja = new JSONArray();
for (ModelInfo info : localModels) {
JSONObject jo = new JSONObject();
jo.put("name", info.getName());
jo.put("type", info.getType());
jo.put("size", info.getSize());
jo.put("save_path", info.getSave_path());
ja.put(jo);
}
sendJsonResponse(exchange, 200, ja.toString());
} catch (Exception e) {
sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}");
} finally {
exchange.close();
}
}
}
private class ArchiveHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!handleSecurity(exchange, "POST")) return;
try (InputStream is = exchange.getRequestBody()) {
String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
JSONArray paths = new JSONArray(body);
int successCount = 0;
for (int i = 0; i < paths.length(); i++) {
try {
archiveService.moveToArchive(paths.getString(i));
successCount++;
} catch (Exception e) {
e.printStackTrace();
}
}
sendJsonResponse(exchange, 200, "{\"status\": \"success\", \"archived\": " + successCount + "}");
} catch (Exception e) {
sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}");
} finally {
exchange.close();
}
}
}
private class ImportHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
// Add CORS headers to EVERY response
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "POST, OPTIONS, GET");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type, Authorization");
// Handle preflight OPTIONS request
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
// SECURITY: Validate API Token
String authHeader = exchange.getRequestHeaders().getFirst("Authorization");
if (expectedApiToken != null && !expectedApiToken.isEmpty()) {
if (authHeader == null || !authHeader.equals("Bearer " + expectedApiToken)) {
System.err.println("REST Bridge: Unauthorized request (Invalid or Missing Token)");
exchange.sendResponseHeaders(401, -1);
return;
}
}
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
if (!handleSecurity(exchange, "POST")) return;
try (InputStream is = exchange.getRequestBody()) {
String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
if (workflowConsumer != null) {
workflowConsumer.accept(body);
String response = "{\"status\": \"success\", \"message\": \"Workflow received\"}";
byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, responseBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(responseBytes);
}
sendJsonResponse(exchange, 200, "{\"status\": \"success\", \"message\": \"Workflow received\"}");
} else {
String response = "{\"status\": \"error\", \"message\": \"App not ready\"}";
byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(503, responseBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(responseBytes);
}
sendJsonResponse(exchange, 503, "{\"status\": \"error\", \"message\": \"App not ready\"}");
}
}
} catch (Exception e) {
String response = "{\"status\": \"error\", \"message\": \"" + e.getMessage() + "\"}";
byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8);
try {
exchange.sendResponseHeaders(500, responseBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(responseBytes);
}
} catch (IOException ignored) {}
sendJsonResponse(exchange, 500, "{\"status\": \"error\", \"message\": \"" + e.getMessage() + "\"}");
} finally {
exchange.close();
}
@@ -0,0 +1,86 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.IThumbnailService;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.function.Consumer;
@Service
public class ThumbnailService implements IThumbnailService {
private final ConfigService configService;
private final ModelHashRegistry hashRegistry;
private final HttpClient httpClient;
@Autowired
public ThumbnailService(ConfigService configService, ModelHashRegistry hashRegistry) {
this.configService = configService;
this.hashRegistry = hashRegistry;
this.httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build();
}
protected String getCivitaiApiBaseUrl() {
return "https://civitai.com/api/v1";
}
@Override
public void downloadThumbnail(ModelInfo model, Consumer<Boolean> onResult) {
new Thread(() -> {
boolean success = performDownload(model);
if (onResult != null) onResult.accept(success);
}).start();
}
private boolean performDownload(ModelInfo model) {
try {
String modelsPath = configService.getModelsPath();
String subPath = (model.getSave_path() != null ? model.getSave_path() : (model.getType() != null ? model.getType() : "checkpoints"));
File localFile = new File(modelsPath, subPath + File.separator + model.getName());
if (!localFile.exists()) return false;
String hash = hashRegistry.getOrCalculateHash(localFile);
if (hash == null) return false;
String hashUrl = getCivitaiApiBaseUrl() + "/model-versions/by-hash/" + hash;
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder().uri(URI.create(hashUrl)).build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) return false;
JSONObject version = new JSONObject(response.body());
JSONArray images = version.optJSONArray("images");
if (images == null || images.length() == 0) return false;
// Get first image URL
String imageUrl = images.getJSONObject(0).optString("url");
if (imageUrl == null || imageUrl.isEmpty()) return false;
// Target path: modelname.preview.png
String baseName = model.getName();
if (baseName.contains(".")) baseName = baseName.substring(0, baseName.lastIndexOf("."));
Path previewPath = localFile.getParentFile().toPath().resolve(baseName + ".preview.png");
// Download image
try (InputStream in = httpClient.send(HttpRequest.newBuilder().uri(URI.create(imageUrl)).build(), HttpResponse.BodyHandlers.ofInputStream()).body()) {
Files.copy(in, previewPath, StandardCopyOption.REPLACE_EXISTING);
return true;
}
} catch (Exception e) {
System.err.println("Error downloading thumbnail for " + model.getName() + ": " + e.getMessage());
}
return false;
}
}
@@ -0,0 +1,54 @@
package de.tki.comfymodels.ui;
import javax.swing.*;
import javax.swing.plaf.ColorUIResource;
import java.awt.*;
/**
* A custom JPanel that draws a dot grid background,
* similar to the ComfyUI workspace.
* Now theme-aware and robust for LookAndFeel changes.
*/
public class DotGridPanel extends JPanel {
private static final int GRID_SPACING = 25;
private static final int DOT_SIZE = 2;
public DotGridPanel(LayoutManager layout) {
super(layout);
setOpaque(true);
}
@Override
public void updateUI() {
super.updateUI();
// Reset background to null to let the new LookAndFeel default take over
setBackground(null);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
// Use a subtle dot grid based on foreground or a calculated color
Color fg = UIManager.getColor("Separator.foreground");
if (fg == null) fg = new Color(128, 128, 128, 60);
else {
// Make it semi-transparent for the grid effect
fg = new Color(fg.getRed(), fg.getGreen(), fg.getBlue(), 40);
}
g2.setColor(fg);
int width = getWidth();
int height = getHeight();
for (int x = 0; x < width; x += GRID_SPACING) {
for (int y = 0; y < height; y += GRID_SPACING) {
g2.fillOval(x, y, DOT_SIZE, DOT_SIZE);
}
}
g2.dispose();
}
}
@@ -0,0 +1,57 @@
package de.tki.comfymodels.util;
import java.io.IOException;
public class PlatformUtils {
private static String OS_NAME = System.getProperty("os.name").toLowerCase();
public static void setOsNameForTesting(String osName) {
OS_NAME = osName.toLowerCase();
}
public static boolean isWindows() {
return OS_NAME.contains("win");
}
public static boolean isMac() {
return OS_NAME.contains("mac");
}
public static boolean isLinux() {
return OS_NAME.contains("nix") || OS_NAME.contains("nux") || OS_NAME.contains("aix");
}
public static void shutdownSystem() {
String[] command = getShutdownCommand();
if (command == null) {
System.err.println("Shutdown not supported on this OS: " + OS_NAME);
return;
}
try {
new ProcessBuilder(command).start();
} catch (IOException e) {
System.err.println("Error executing shutdown command: " + e.getMessage());
}
}
public static String[] getShutdownCommand() {
if (isWindows()) {
return new String[]{"shutdown", "/s", "/t", "60"};
} else if (isMac()) {
return new String[]{"osascript", "-e", "tell app \"System Events\" to shut down"};
} else if (isLinux()) {
return new String[]{"shutdown", "-h", "+1"};
}
return null;
}
public static boolean isSystemTraySupported() {
try {
return java.awt.SystemTray.isSupported();
} catch (Exception e) {
return false;
}
}
}