mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
The torchvision compatibility shim for basicsr 1.4.2 was missing the parent-package binding and only proxied a single attribute, causing upscale and enhance-faces to fail at import time. The fix adds a __getattr__ proxy for all attributes, binds the shim to the parent package, and installs it in the dispatcher at startup for defense-in-depth. Also removes unused anyInstalling variable, redundant `as any` cast, and applies Biome formatting fixes across the codebase.
282 lines
9.0 KiB
TypeScript
282 lines
9.0 KiB
TypeScript
/**
|
|
* Feature bundle management routes.
|
|
*
|
|
* GET /api/v1/features — List feature bundles and their statuses
|
|
* POST /api/v1/admin/features/:bundleId/install — Install a feature bundle (async)
|
|
* POST /api/v1/admin/features/:bundleId/uninstall — Uninstall a feature bundle
|
|
* GET /api/v1/admin/features/disk-usage — Get AI model disk usage
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { shutdownDispatcher } from "@ashim/ai";
|
|
import { FEATURE_BUNDLES } from "@ashim/shared";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import {
|
|
acquireInstallLock,
|
|
getAiDir,
|
|
getFeatureStates,
|
|
getManifestPath,
|
|
getModelsDir,
|
|
invalidateCache,
|
|
isDockerEnvironment,
|
|
isFeatureInstalled,
|
|
markUninstalled,
|
|
releaseInstallLock,
|
|
setInstallProgress,
|
|
} from "../lib/feature-status.js";
|
|
import { requireAdmin, requireAuth } from "../plugins/auth.js";
|
|
import { updateSingleFileProgress } from "./progress.js";
|
|
|
|
const venvPath = process.env.PYTHON_VENV_PATH || "/opt/venv";
|
|
const pythonPath = `${venvPath}/bin/python3`;
|
|
|
|
interface BundleIdParams {
|
|
bundleId: string;
|
|
}
|
|
|
|
interface ManifestModel {
|
|
id: string;
|
|
path?: string;
|
|
}
|
|
|
|
interface ManifestBundle {
|
|
models: ManifestModel[];
|
|
}
|
|
|
|
interface Manifest {
|
|
bundles: Record<string, ManifestBundle>;
|
|
}
|
|
|
|
function readManifest(): Manifest | null {
|
|
const manifestPath = getManifestPath();
|
|
if (!existsSync(manifestPath)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(manifestPath, "utf-8")) as Manifest;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Recursively calculate total size of a directory in bytes. */
|
|
function getDirSize(dirPath: string): number {
|
|
if (!existsSync(dirPath)) return 0;
|
|
|
|
let total = 0;
|
|
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = join(dirPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
total += getDirSize(fullPath);
|
|
} else if (entry.isFile()) {
|
|
try {
|
|
total += statSync(fullPath).size;
|
|
} catch {
|
|
// File may have been deleted between readdir and stat
|
|
}
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export async function registerFeatureRoutes(app: FastifyInstance): Promise<void> {
|
|
// GET /api/v1/features — List feature bundles and their statuses
|
|
app.get("/api/v1/features", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const user = requireAuth(request, reply);
|
|
if (!user) return;
|
|
|
|
// In non-Docker environments, all bundles are available natively
|
|
if (!isDockerEnvironment()) {
|
|
const bundles = Object.values(FEATURE_BUNDLES).map((bundle) => ({
|
|
id: bundle.id,
|
|
name: bundle.name,
|
|
description: bundle.description,
|
|
status: "installed" as const,
|
|
installedVersion: null,
|
|
estimatedSize: bundle.estimatedSize,
|
|
enablesTools: bundle.enablesTools,
|
|
progress: null,
|
|
error: null,
|
|
}));
|
|
return reply.send({ bundles });
|
|
}
|
|
|
|
return reply.send({ bundles: getFeatureStates() });
|
|
});
|
|
|
|
// POST /api/v1/admin/features/:bundleId/install — Install a feature bundle
|
|
app.post(
|
|
"/api/v1/admin/features/:bundleId/install",
|
|
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
|
|
const admin = requireAdmin(request, reply);
|
|
if (!admin) return;
|
|
|
|
const { bundleId } = request.params;
|
|
|
|
if (!FEATURE_BUNDLES[bundleId]) {
|
|
return reply.status(404).send({ error: `Unknown bundle: ${bundleId}` });
|
|
}
|
|
|
|
if (isFeatureInstalled(bundleId)) {
|
|
return reply.status(409).send({ error: `Bundle "${bundleId}" is already installed` });
|
|
}
|
|
|
|
if (!acquireInstallLock(bundleId)) {
|
|
return reply.status(409).send({ error: "Another install is already in progress" });
|
|
}
|
|
|
|
const jobId = crypto.randomUUID();
|
|
const scriptPath = "/app/packages/ai/python/install_feature.py";
|
|
const manifestPath = getManifestPath();
|
|
const modelsDir = getModelsDir();
|
|
|
|
const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
BUNDLE_ID: bundleId,
|
|
PIP_CACHE_DIR: join(getAiDir(), "pip-cache"),
|
|
},
|
|
});
|
|
|
|
let stderrBuffer = "";
|
|
let stdoutBuffer = "";
|
|
const lastStderrLines: string[] = [];
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
stdoutBuffer += chunk.toString();
|
|
});
|
|
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
stderrBuffer += chunk.toString();
|
|
|
|
const lines = stderrBuffer.split("\n");
|
|
stderrBuffer = lines.pop() ?? "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
lastStderrLines.push(trimmed);
|
|
if (lastStderrLines.length > 20) lastStderrLines.shift();
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string };
|
|
if (typeof parsed.progress === "number") {
|
|
setInstallProgress(
|
|
bundleId,
|
|
{ percent: parsed.progress, stage: parsed.stage ?? "" },
|
|
null,
|
|
);
|
|
updateSingleFileProgress({
|
|
jobId,
|
|
phase: "processing",
|
|
percent: parsed.progress,
|
|
stage: parsed.stage,
|
|
});
|
|
}
|
|
} catch {
|
|
// Not JSON progress — rembg/pip output noise, keep in lastStderrLines for error reporting
|
|
}
|
|
}
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
releaseInstallLock();
|
|
|
|
if (code === 0) {
|
|
invalidateCache();
|
|
shutdownDispatcher();
|
|
setInstallProgress(null, null, null);
|
|
updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" });
|
|
} else {
|
|
const errorDetail =
|
|
lastStderrLines.filter((l) => !l.startsWith("{")).join("\n") || stdoutBuffer.trim();
|
|
const errorMsg = errorDetail || `Install failed with exit code ${code}`;
|
|
setInstallProgress(bundleId, null, errorMsg);
|
|
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
|
|
}
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
releaseInstallLock();
|
|
const errorMsg = `Failed to spawn install process: ${err.message}`;
|
|
setInstallProgress(bundleId, null, errorMsg);
|
|
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
|
|
});
|
|
|
|
return reply.status(202).send({ jobId });
|
|
},
|
|
);
|
|
|
|
// POST /api/v1/admin/features/:bundleId/uninstall — Uninstall a feature bundle
|
|
app.post(
|
|
"/api/v1/admin/features/:bundleId/uninstall",
|
|
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
|
|
const admin = requireAdmin(request, reply);
|
|
if (!admin) return;
|
|
|
|
const { bundleId } = request.params;
|
|
|
|
if (!FEATURE_BUNDLES[bundleId]) {
|
|
return reply.status(404).send({ error: `Unknown bundle: ${bundleId}` });
|
|
}
|
|
|
|
if (!isFeatureInstalled(bundleId)) {
|
|
return reply.status(409).send({ error: `Bundle "${bundleId}" is not installed` });
|
|
}
|
|
|
|
// Read manifest to find model files to delete, but skip models
|
|
// that are still needed by another installed bundle.
|
|
const manifest = readManifest();
|
|
if (manifest) {
|
|
const manifestBundle = manifest.bundles[bundleId];
|
|
if (manifestBundle) {
|
|
// Collect model paths that OTHER installed bundles still need
|
|
const sharedPaths = new Set<string>();
|
|
for (const [otherId, otherBundle] of Object.entries(manifest.bundles)) {
|
|
if (otherId === bundleId) continue;
|
|
if (!isFeatureInstalled(otherId)) continue;
|
|
for (const m of otherBundle.models ?? []) {
|
|
if (m.path) sharedPaths.add(m.path);
|
|
}
|
|
}
|
|
|
|
const modelsDir = getModelsDir();
|
|
for (const model of manifestBundle.models) {
|
|
if (!model.path) continue;
|
|
if (sharedPaths.has(model.path)) continue; // still needed
|
|
const modelPath = join(modelsDir, model.path);
|
|
try {
|
|
if (existsSync(modelPath)) {
|
|
unlinkSync(modelPath);
|
|
}
|
|
} catch {
|
|
// Best-effort deletion
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
markUninstalled(bundleId);
|
|
shutdownDispatcher();
|
|
|
|
return reply.send({ ok: true });
|
|
},
|
|
);
|
|
|
|
// GET /api/v1/admin/features/disk-usage — Get AI model disk usage
|
|
app.get(
|
|
"/api/v1/admin/features/disk-usage",
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const admin = requireAdmin(request, reply);
|
|
if (!admin) return;
|
|
|
|
const totalBytes = getDirSize(getAiDir());
|
|
return reply.send({ totalBytes });
|
|
},
|
|
);
|
|
}
|