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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user