Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions backend/app/routes/api.diarization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Proxy routes for the diarization/speaker recognition service.
* Forwards requests to the diarization service (default: http://localhost:8085).
*/
import type { Request, Response } from "express";
import { authenticateOr401 } from "../lib/auth/core.server.ts";

// Get diarization service URL from environment or use default
const DIARIZATION_SERVICE_URL = (
Deno.env.get("DIARIZATION_SERVER_URL") ||
Deno.env.get("SPEAKER_SERVICE_URL") ||
"http://localhost:8085"
).replace(/\/$/, "");

/**
* Proxy handler for diarization service requests.
* Supports both GET and POST requests, forwarding to the speaker recognition service.
*/
export async function apiDiarizationProxyHandler(
req: Request,
res: Response
): Promise<void> {
try {
// Authenticate the request
await authenticateOr401(req, res);

// Extract the path after /api/diarization
const subPath = req.params[0] || "";
const targetUrl = `${DIARIZATION_SERVICE_URL}/${subPath}`;

// Build query string if present
const queryString = new URLSearchParams(req.query as Record<string, string>).toString();
const fullUrl = queryString ? `${targetUrl}?${queryString}` : targetUrl;

console.log(`[diarization-proxy] ${req.method} ${fullUrl}`);

// Prepare headers (forward auth if needed, but diarization service is internal)
const headers: Record<string, string> = {
"Accept": "application/json",
};

// Handle different request types
let body: BodyInit | undefined;
const contentType = req.headers["content-type"] || "";

if (req.method !== "GET" && req.method !== "HEAD") {
if (contentType.includes("multipart/form-data")) {
// For multipart requests, we need to reconstruct the form data
// This is tricky with Express - we'll pass through the raw request
// Actually, since Express parses the body, we need to handle this differently

// For file uploads, we need to handle this specially
// The frontend should send directly to the diarization service for file uploads
// Or we need to use a middleware like multer

// For now, let's handle JSON requests and simple form data
res.status(400).json({
error: "File upload proxy not yet implemented. Please use direct upload to diarization service."
Comment on lines +53 to +58

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message suggests users should "use direct upload to diarization service", but this is misleading since the diarization service is an internal backend service not accessible to frontend clients. The frontend must go through the backend proxy for authentication. This error message should be removed once proper multipart handling is implemented.

Suggested change
// The frontend should send directly to the diarization service for file uploads
// Or we need to use a middleware like multer
// For now, let's handle JSON requests and simple form data
res.status(400).json({
error: "File upload proxy not yet implemented. Please use direct upload to diarization service."
// Multipart/form-data proxying (including file uploads) is not yet implemented here
// and will require additional middleware (e.g., multer) to correctly forward uploads.
// For now, only JSON requests and simple form data are supported via this proxy.
res.status(400).json({
error: "File upload proxy for multipart/form-data is not yet implemented. Multipart file uploads via this endpoint are currently unsupported."

Copilot uses AI. Check for mistakes.
});
Comment on lines +48 to +59

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The multipart/form-data handling returns an error stating the feature is not implemented. However, there's a separate apiDiarizationEnrollHandler function below (lines 117-172) that implements this functionality but is never registered. This creates a confusing situation where enrollment via file upload will always fail with an unhelpful error message. Either implement multipart handling here or register and use the dedicated enrollment handler.

Suggested change
// For multipart requests, we need to reconstruct the form data
// This is tricky with Express - we'll pass through the raw request
// Actually, since Express parses the body, we need to handle this differently
// For file uploads, we need to handle this specially
// The frontend should send directly to the diarization service for file uploads
// Or we need to use a middleware like multer
// For now, let's handle JSON requests and simple form data
res.status(400).json({
error: "File upload proxy not yet implemented. Please use direct upload to diarization service."
});
// Delegate multipart/form-data (file upload) handling to the dedicated
// enrollment handler, which is implemented below in this file.
// This avoids returning a misleading "not implemented" error while
// reusing the existing logic for enrollment via file upload.
await apiDiarizationEnrollHandler(req, res);

Copilot uses AI. Check for mistakes.
return;
} else if (contentType.includes("application/json")) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(req.body);
} else if (req.body) {
headers["Content-Type"] = contentType;
body = JSON.stringify(req.body);
}
}

// Make the proxied request
const proxyResponse = await fetch(fullUrl, {
method: req.method,
headers,
body,
});

// Forward the response
const responseContentType = proxyResponse.headers.get("content-type") || "application/json";
res.status(proxyResponse.status);
res.setHeader("Content-Type", responseContentType);

if (responseContentType.includes("application/json")) {
const data = await proxyResponse.json();
res.json(data);
} else {
const buffer = await proxyResponse.arrayBuffer();
res.send(Buffer.from(buffer));
}
} catch (error) {
if (error instanceof Error && error.message === "Unauthorized") {
return; // Already sent 401 response
}
Comment on lines +90 to +92

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error checking for "Unauthorized" by comparing error message strings is fragile. The authenticateOr401 function throws a Response object via permissionDenied, not an Error with message "Unauthorized". This catch block will never match. The error handler middleware already handles Response objects correctly (see errorHandler.ts line 55-62), so this check can be removed.

Copilot uses AI. Check for mistakes.

console.error("[diarization-proxy] Error:", error);

// Check if it's a connection error to the diarization service
if (error instanceof TypeError && error.message.includes("fetch failed")) {
res.status(503).json({
error: "Diarization service unavailable",
message: `Could not connect to diarization service at ${DIARIZATION_SERVICE_URL}`,
hint: "Make sure the diarization service is running (docker compose up diarization)"
});
return;
}

res.status(500).json({
error: "Internal server error",
message: error instanceof Error ? error.message : String(error)
});
}
}

/**
* Dedicated handler for speaker enrollment with file uploads.
* Uses multer or similar for handling multipart/form-data.
*/
export async function apiDiarizationEnrollHandler(
req: Request,
res: Response
): Promise<void> {
try {
await authenticateOr401(req, res);

// For file uploads, we need to stream the request body
// This is a special case that requires different handling

const targetUrl = `${DIARIZATION_SERVICE_URL}/enroll/batch`;
console.log(`[diarization-proxy] POST ${targetUrl} (file upload)`);

// Get the raw content-type header including boundary
const contentType = req.headers["content-type"];
if (!contentType?.includes("multipart/form-data")) {
res.status(400).json({ error: "Expected multipart/form-data" });
return;
}

// For multipart uploads, we need to forward the raw body
// This requires collecting the raw body chunks
const chunks: Uint8Array[] = [];

await new Promise<void>((resolve, reject) => {
req.on("data", (chunk: Buffer) => {
chunks.push(new Uint8Array(chunk));
});
req.on("end", resolve);
req.on("error", reject);
});

const bodyBuffer = Buffer.concat(chunks.map(c => Buffer.from(c)));

const proxyResponse = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": contentType,
},
body: bodyBuffer,
});

const data = await proxyResponse.json();
res.status(proxyResponse.status).json(data);
} catch (error) {
if (error instanceof Error && error.message === "Unauthorized") {
return;
}

Comment on lines +162 to +165

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as line 90-92: the "Unauthorized" error check will never match because authenticateOr401 throws a Response object, not an Error. This check can be removed as the error handler middleware will properly handle authentication failures.

Suggested change
if (error instanceof Error && error.message === "Unauthorized") {
return;
}

Copilot uses AI. Check for mistakes.
console.error("[diarization-proxy] Enroll error:", error);
res.status(500).json({
error: "Enrollment failed",
message: error instanceof Error ? error.message : String(error)
});
}
}
3 changes: 3 additions & 0 deletions backend/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { authJwtLoginHandler } from "@/routes/auth.jwt.login.ts";
import { wellKnownOauthAuthorizationServerHandler } from "@/routes/[.]well-known.oauth-authorization-server.ts";
import { wellKnownOauthProtectedResourceHandler } from "@/routes/[.]well-known.oauth-protected-resource.ts";
import { apiChatHandler } from "@/routes/api.chat.ts";
import { apiDiarizationProxyHandler, apiDiarizationEnrollHandler } from "@/routes/api.diarization.ts";

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apiDiarizationEnrollHandler is imported but never registered as a route in the Express app. This handler appears to be specifically designed to handle multipart file uploads for speaker enrollment, but it's not being used. Either this export should be removed, or a route should be registered for it (e.g., app.post("/api/diarization/enroll/batch", asyncHandler(apiDiarizationEnrollHandler))).

Suggested change
import { apiDiarizationProxyHandler, apiDiarizationEnrollHandler } from "@/routes/api.diarization.ts";
import { apiDiarizationProxyHandler } from "@/routes/api.diarization.ts";

Copilot uses AI. Check for mistakes.
import { asyncHandler } from "@/middleware/asyncHandler.ts";

export function registerRoutes(app: Express): void {
Expand All @@ -38,6 +39,8 @@ export function registerRoutes(app: Express): void {
app.get("/api/audio/stream", apiAudioStreamHandler);
app.post("/api/audio/upload", apiAudioUploadHandler);
app.get("/api/audio/wav", apiAudioWavHandler);
// Diarization service proxy routes
app.all("/api/diarization/*", asyncHandler(apiDiarizationProxyHandler));
app.get("/mcp", mcpGetHandler);
app.post("/mcp", mcpPostHandler);
app.post("/llm/chat/completions", llmChatCompletionsHandler);
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, Navigate, Outlet, useLocation } from "react-router-dom";
import { Clock, Home, Package, Settings, MessageSquare, Activity, Mic } from "lucide-react";
import { Clock, Home, Package, Settings, MessageSquare, Activity, Mic, Users } from "lucide-react";

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import Home.

Suggested change
import { Clock, Home, Package, Settings, MessageSquare, Activity, Mic, Users } from "lucide-react";
import { Clock, Package, Settings, MessageSquare, Activity, Mic, Users } from "lucide-react";

Copilot uses AI. Check for mistakes.
import { useTheme } from "@/hooks/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
Expand Down Expand Up @@ -92,6 +92,16 @@ const Layout = () => {
<Mic className="w-4 h-4" />
Pipeline
</Link>
<Link
to="/speakers"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors relative ${location.pathname === "/speakers"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<Users className="w-4 h-4" />
Speakers
</Link>
<Link
to="/settings"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors relative ${location.pathname === "/settings"
Expand Down
Loading
Loading