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
@@ -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;
}
}