Files
homarr/tools/assistant-benchmark/server.mjs
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

622 lines
20 KiB
JavaScript

import { createServer } from "node:http";
const HOST = "127.0.0.1";
const PORT = 18094;
const SONARR_API_KEY = "benchmark-sonarr-key";
const MEALIE_TOKEN = "benchmark-mealie-token";
const DEFAULT_BENCHMARK_DATE = "2026-09-24";
const BENCHMARK_DATE = process.env.BENCHMARK_DATE ?? DEFAULT_BENCHMARK_DATE;
if (
!/^\d{4}-\d{2}-\d{2}$/.test(BENCHMARK_DATE) ||
Number.isNaN(Date.parse(`${BENCHMARK_DATE}T00:00:00Z`)) ||
new Date(`${BENCHMARK_DATE}T00:00:00Z`).toISOString().slice(0, 10) !== BENCHMARK_DATE
) {
throw new Error("BENCHMARK_DATE must be a valid YYYY-MM-DD date");
}
const routes = new Set([
"/health",
"/sonarr/api/v3/series",
"/sonarr/api/v3/queue",
"/mealie/api/households/mealplans/today",
"/mealie/api/households/mealplans",
"/openlibrary/search.json",
"/openmeteo/v1/search",
"/openmeteo/v1/forecast",
]);
const requestCounts = new Map();
function sendJson(response, status, payload, headers = {}) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...headers,
});
response.end(JSON.stringify(payload));
}
function recordRequest(method, pathname) {
const key = `${method} ${pathname}`;
requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1);
}
function addDays(date, days) {
const value = new Date(`${date}T00:00:00.000Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
// Response fields match Homarr's validated Sonarr schemas in
// packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts.
const sonarrSeries = [
{
id: 101,
title: "Severance",
titleSlug: "severance",
year: 2022,
monitored: true,
statistics: { totalEpisodeCount: 19, episodeFileCount: 18, sizeOnDisk: 18400000000 },
},
{
id: 102,
title: "The Expanse",
titleSlug: "the-expanse",
year: 2015,
monitored: true,
statistics: { totalEpisodeCount: 62, episodeFileCount: 62, sizeOnDisk: 58200000000 },
},
{
id: 103,
title: "Synthetic Unmonitored Series",
titleSlug: "synthetic-unmonitored-series",
year: 2024,
monitored: false,
statistics: { totalEpisodeCount: 8, episodeFileCount: 3, sizeOnDisk: 2900000000 },
},
];
const sonarrQueue = {
page: 1,
pageSize: 10,
totalRecords: 2,
records: [
{
id: 501,
title: "Severance - S02E05 - Trojan's Horse",
status: "downloading",
timeleft: "00:18:42",
size: 4200000000,
sizeleft: 1260000000,
series: { title: "Severance", titleSlug: "severance", images: [] },
episode: { title: "Trojan's Horse", seasonNumber: 2, episodeNumber: 5 },
},
{
id: 502,
title: "The Expanse - S04E01 - New Terra",
status: "queued",
timeleft: "01:06:10",
size: 2800000000,
sizeleft: 2800000000,
series: { title: "The Expanse", titleSlug: "the-expanse", images: [] },
episode: { title: "New Terra", seasonNumber: 4, episodeNumber: 1 },
},
],
};
// These synthetic records follow Mealie v2 PlanEntry and PlanEntryPagination.
// Reference: https://mealie.io/documentation/community-guide/home-assistant/
const mealieIds = {
household: "20000000-0000-4000-8000-000000000001",
group: "20000000-0000-4000-8000-000000000002",
user: "20000000-0000-4000-8000-000000000003",
};
function mealieRecipe({ id, name, slug, description, totalTime, prepTime, cookTime, servings }) {
return {
id,
userId: mealieIds.user,
householdId: mealieIds.household,
groupId: mealieIds.group,
name,
slug,
image: null,
recipeServings: servings,
recipeYieldQuantity: servings,
recipeYield: "servings",
totalTime,
prepTime,
cookTime,
performTime: cookTime,
description,
recipeCategory: [],
tags: [],
tools: [],
rating: null,
orgURL: null,
dateAdded: BENCHMARK_DATE,
};
}
const mealPlans = [
{
id: 1701,
date: BENCHMARK_DATE,
entryType: "lunch",
title: "",
text: "",
recipeId: "10000000-0000-4000-8000-000000000001",
groupId: mealieIds.group,
userId: mealieIds.user,
householdId: mealieIds.household,
recipe: mealieRecipe({
id: "10000000-0000-4000-8000-000000000001",
name: "Sesame Ginger Tofu Rice Bowl",
slug: "sesame-ginger-tofu-rice-bowl",
description: "Crispy tofu, steamed rice, and quick-pickled vegetables with sesame ginger sauce.",
totalTime: "PT25M",
prepTime: "PT15M",
cookTime: "PT10M",
servings: 2,
}),
},
{
id: 1702,
date: BENCHMARK_DATE,
entryType: "dinner",
title: "",
text: "",
recipeId: "10000000-0000-4000-8000-000000000002",
groupId: mealieIds.group,
userId: mealieIds.user,
householdId: mealieIds.household,
recipe: mealieRecipe({
id: "10000000-0000-4000-8000-000000000002",
name: "Roasted Tomato and Red Lentil Soup",
slug: "roasted-tomato-red-lentil-soup",
description: "A hearty red lentil soup with roasted tomatoes, cumin, and lemon.",
totalTime: "PT45M",
prepTime: "PT10M",
cookTime: "PT35M",
servings: 4,
}),
},
{
id: 1703,
date: addDays(BENCHMARK_DATE, 1),
entryType: "lunch",
title: "",
text: "",
recipeId: "10000000-0000-4000-8000-000000000003",
groupId: mealieIds.group,
userId: mealieIds.user,
householdId: mealieIds.household,
recipe: mealieRecipe({
id: "10000000-0000-4000-8000-000000000003",
name: "Lemon Herb Chickpea Salad",
slug: "lemon-herb-chickpea-salad",
description: "Chickpeas, cucumber, parsley, and lemon vinaigrette served over greens.",
totalTime: "PT15M",
prepTime: "PT15M",
cookTime: "PT0M",
servings: 2,
}),
},
];
// Search documents use the official Open Library Search API field names.
// Reference: https://openlibrary.org/dev/docs/api/search
const books = [
{
key: "/works/OL9999001W",
title: "Dune",
author_name: ["Frank Herbert"],
first_publish_year: 1965,
edition_count: 40,
cover_i: 9999001,
author_key: ["OL9999001A"],
},
{
key: "/works/OL9999002W",
title: "Dune Messiah",
author_name: ["Frank Herbert"],
first_publish_year: 1969,
edition_count: 25,
cover_i: 9999002,
author_key: ["OL9999001A"],
},
{
key: "/works/OL9999003W",
title: "The Hobbit",
author_name: ["J. R. R. Tolkien"],
first_publish_year: 1937,
edition_count: 180,
cover_i: 9999003,
author_key: ["OL9999003A"],
},
];
const geocodingLocations = [
{
id: 2950159,
name: "Berlin",
latitude: 52.52437,
longitude: 13.41053,
elevation: 74,
feature_code: "PPLC",
country_code: "DE",
admin1_id: 2950157,
admin2_id: 0,
admin3_id: 6547383,
admin4_id: 6547539,
timezone: "Europe/Berlin",
population: 3426354,
postcodes: ["10967", "13347"],
country_id: 2921044,
country: "Germany",
admin1: "Berlin",
admin2: "",
admin3: "Berlin, Stadt",
admin4: "Berlin",
},
{
id: 2988507,
name: "Paris",
latitude: 48.85341,
longitude: 2.3488,
elevation: 42,
feature_code: "PPLC",
country_code: "FR",
admin1_id: 3012874,
admin2_id: 0,
timezone: "Europe/Paris",
population: 2138551,
postcodes: ["75001", "75002"],
country_id: 3017382,
country: "France",
admin1: "Île-de-France",
admin2: "",
},
];
// Open-Meteo uses separate forecast and geocoding hosts in production; both
// official /v1 paths are mounted below /openmeteo for this single-origin fixture.
// References: https://open-meteo.com/en/docs and https://open-meteo.com/en/docs/geocoding-api
const currentUnit = {
temperature_2m: "°C",
apparent_temperature: "°C",
relative_humidity_2m: "%",
precipitation: "mm",
is_day: "",
weather_code: "wmo code",
};
const dailyUnit = {
temperature_2m_max: "°C",
temperature_2m_min: "°C",
apparent_temperature_max: "°C",
precipitation_probability_max: "%",
precipitation_sum: "mm",
rain_sum: "mm",
precipitation_hours: "h",
weather_code: "wmo code",
sunrise: "iso8601",
sunset: "iso8601",
};
function requestedVariables(url, name, fallback) {
const values = url.searchParams.getAll(name).flatMap((value) => value.split(","));
return values.length > 0 ? values.filter(Boolean) : fallback;
}
function celsiusToRequestedUnit(value, unit) {
if (unit === "fahrenheit") return Math.round((value * 9) / 5 + 32);
return Math.round(value * 10) / 10;
}
function timezoneOffsetSeconds(date, timezone) {
const utcDate = new Date(`${date}T12:00:00.000Z`);
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(utcDate);
const fields = Object.fromEntries(
parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]),
);
const localAsUtc = Date.UTC(
fields.year,
Number(fields.month) - 1,
fields.day,
fields.hour,
fields.minute,
fields.second,
);
return Math.round((localAsUtc - utcDate.getTime()) / 1000);
}
function timezoneAbbreviation(date, timezone) {
return (
new Intl.DateTimeFormat("en", { timeZone: timezone, timeZoneName: "short" })
.formatToParts(new Date(`${date}T12:00:00.000Z`))
.find((part) => part.type === "timeZoneName")?.value ?? "GMT"
);
}
function forecastResponse(url) {
const latitude = Number(url.searchParams.get("latitude"));
const longitude = Number(url.searchParams.get("longitude"));
if (
!url.searchParams.get("latitude")?.trim() ||
!url.searchParams.get("longitude")?.trim() ||
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
) {
return { status: 400, body: { error: true, reason: "latitude and longitude are required" } };
}
const temperatureUnit = url.searchParams.get("temperature_unit") ?? "celsius";
if (!new Set(["celsius", "fahrenheit"]).has(temperatureUnit)) {
return { status: 400, body: { error: true, reason: "temperature_unit must be celsius or fahrenheit" } };
}
const forecastDays = Number(url.searchParams.get("forecast_days") ?? 7);
if (!Number.isInteger(forecastDays) || forecastDays < 1 || forecastDays > 16) {
return { status: 400, body: { error: true, reason: "forecast_days must be between 1 and 16" } };
}
const timezone = url.searchParams.get("timezone") ?? "GMT";
try {
new Intl.DateTimeFormat("en", { timeZone: timezone }).format();
} catch {
return { status: 400, body: { error: true, reason: "Unsupported fixture timezone" } };
}
const currentVariables = requestedVariables(url, "current", ["temperature_2m", "apparent_temperature"]);
const dailyVariables = requestedVariables(url, "daily", ["precipitation_probability_max"]);
if (
currentVariables.some((name) => !Object.hasOwn(currentUnit, name)) ||
dailyVariables.some((name) => !Object.hasOwn(dailyUnit, name))
) {
return { status: 400, body: { error: true, reason: "Unsupported fixture weather variable" } };
}
const dateAtLocation = (day) => addDays(BENCHMARK_DATE, day);
const locationBias = Math.round((Math.abs(latitude) * 0.73 + Math.abs(longitude) * 0.41) % 9) - 4;
const baseCelsius = 17 + locationBias;
const unitSuffix = temperatureUnit === "fahrenheit" ? "°F" : "°C";
const precipitationUnit = url.searchParams.get("precipitation_unit") === "inch" ? "inch" : "mm";
const precipitationMultiplier = precipitationUnit === "inch" ? 1 / 25.4 : 1;
const precipitationLabel = precipitationUnit === "inch" ? "inch" : "mm";
const offsetSeconds = timezoneOffsetSeconds(BENCHMARK_DATE, timezone);
const currentFieldValues = {
temperature_2m: celsiusToRequestedUnit(baseCelsius, temperatureUnit),
apparent_temperature: celsiusToRequestedUnit(baseCelsius + 1.4, temperatureUnit),
relative_humidity_2m: 61,
precipitation: Number((0.2 * precipitationMultiplier).toFixed(2)),
is_day: 1,
weather_code: 2,
};
const current = { time: `${BENCHMARK_DATE}T12:00`, interval: 900 };
const currentUnits = { time: "iso8601", interval: "seconds" };
for (const variable of currentVariables) {
if (Object.hasOwn(currentFieldValues, variable)) {
current[variable] = currentFieldValues[variable];
currentUnits[variable] =
variable === "temperature_2m" || variable === "apparent_temperature"
? unitSuffix
: variable === "precipitation"
? precipitationLabel
: currentUnit[variable];
}
}
const dailyValues = {
temperature_2m_max: (day) => celsiusToRequestedUnit(baseCelsius + 4 + day * 0.4, temperatureUnit),
temperature_2m_min: (day) => celsiusToRequestedUnit(baseCelsius - 3 + day * 0.2, temperatureUnit),
apparent_temperature_max: (day) => celsiusToRequestedUnit(baseCelsius + 3 + day * 0.5, temperatureUnit),
precipitation_probability_max: (day) => Math.round((Math.abs(latitude * 3 + longitude * 5 + day * 17) % 86) + 5),
precipitation_sum: (day) => Number((((day * 1.7 + locationBias + 8) % 9) * precipitationMultiplier).toFixed(2)),
rain_sum: (day) => Number((((day * 1.2 + locationBias + 5) % 7) * precipitationMultiplier).toFixed(2)),
precipitation_hours: (day) => (day * 2 + 1 + locationBias + 10) % 12,
weather_code: (day) => [2, 3, 61, 1, 80, 2, 3][day % 7],
sunrise: (day) => `${dateAtLocation(day)}T06:${String((12 + day * 2) % 60).padStart(2, "0")}`,
sunset: (day) => `${dateAtLocation(day)}T19:${String((8 - day + 60) % 60).padStart(2, "0")}`,
};
const daily = { time: Array.from({ length: forecastDays }, (_, day) => dateAtLocation(day)) };
const dailyUnits = { time: "iso8601" };
for (const variable of dailyVariables) {
if (Object.hasOwn(dailyValues, variable)) {
daily[variable] = Array.from({ length: forecastDays }, (_, day) => dailyValues[variable](day));
dailyUnits[variable] =
variable.startsWith("temperature_") || variable === "apparent_temperature_max"
? unitSuffix
: variable === "precipitation_sum" || variable === "rain_sum"
? precipitationLabel
: dailyUnit[variable];
}
}
const body = {
latitude,
longitude,
generationtime_ms: 0.01,
utc_offset_seconds: offsetSeconds,
timezone,
timezone_abbreviation: timezoneAbbreviation(BENCHMARK_DATE, timezone),
elevation: 34,
current,
current_units: currentUnits,
daily,
daily_units: dailyUnits,
};
return { status: 200, body };
}
function openLibraryResponse(url) {
const titleFilter = url.searchParams.get("title");
const query = (url.searchParams.get("q") ?? titleFilter ?? "").trim();
const cleanedQuery = query
.replace(/^title\s*:\s*/i, "")
.replace(/["']/g, "")
.toLocaleLowerCase();
const tokens = cleanedQuery.split(/\s+/).filter(Boolean);
const matches =
tokens.length === 0
? []
: books.filter((book) => {
const haystack = `${book.title} ${book.author_name.join(" ")}`.toLocaleLowerCase();
return tokens.every((token) => haystack.includes(token));
});
const offsetValue = Number(url.searchParams.get("offset"));
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit")) || 10));
let start = (page - 1) * limit;
if (url.searchParams.has("offset") && Number.isInteger(offsetValue) && offsetValue >= 0) start = offsetValue;
const requestedFields = url.searchParams.get("fields")?.split(",").filter(Boolean);
const selectedBooks = matches.slice(start, start + limit).map((book) => {
if (!requestedFields || requestedFields.includes("*")) return book;
return Object.fromEntries(
requestedFields.filter((field) => Object.hasOwn(book, field)).map((field) => [field, book[field]]),
);
});
return {
numFound: matches.length,
start,
numFoundExact: true,
docs: selectedBooks,
};
}
function mealPlansResponse(url) {
const startDate = url.searchParams.get("start_date");
const endDate = url.searchParams.get("end_date");
const matching = mealPlans.filter((plan) => {
if (startDate && plan.date < startDate) return false;
if (endDate && plan.date > endDate) return false;
return true;
});
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const requestedPerPage = Number(url.searchParams.get("perPage")) || 50;
const perPage = requestedPerPage === -1 ? Math.max(matching.length, 1) : Math.max(1, requestedPerPage);
const offset = (page - 1) * perPage;
const totalPages = Math.ceil(matching.length / perPage);
return {
page,
per_page: perPage,
total: matching.length,
total_pages: totalPages,
items: matching.slice(offset, offset + perPage),
next: null,
previous: null,
};
}
function geocodingResponse(url) {
const name = url.searchParams.get("name")?.trim();
if (!name) return { status: 400, body: { error: true, reason: "Parameter name is required" } };
const needle = name.toLocaleLowerCase();
const requestedCount = Number(url.searchParams.get("count")) || 10;
if (!Number.isInteger(requestedCount) || requestedCount < 1 || requestedCount > 100) {
return { status: 400, body: { error: true, reason: "Parameter count must be between 1 and 100." } };
}
const countryCode = url.searchParams.get("countryCode")?.toLocaleUpperCase();
const results = geocodingLocations.filter((location) => {
const matchesName =
location.name.toLocaleLowerCase().startsWith(needle) || location.country.toLocaleLowerCase() === needle;
return matchesName && (!countryCode || location.country_code === countryCode);
});
return { status: 200, body: { results: results.slice(0, requestedCount) } };
}
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://${HOST}:${PORT}`);
const pathname = url.pathname;
if (!routes.has(pathname)) {
sendJson(response, 404, { error: "not found" });
return;
}
if (pathname !== "/health") recordRequest(request.method ?? "UNKNOWN", pathname);
if (request.method !== "GET") {
sendJson(response, 405, { error: "method not allowed" }, { Allow: "GET" });
return;
}
if (pathname === "/health") {
sendJson(response, 200, {
status: "ok",
host: HOST,
port: PORT,
date: BENCHMARK_DATE,
services: ["sonarr", "mealie", "openlibrary", "openmeteo"],
requests: [...requestCounts.entries()].map(([key, count]) => {
const splitAt = key.indexOf(" ");
return { method: key.slice(0, splitAt), path: key.slice(splitAt + 1), count };
}),
});
return;
}
if (pathname.startsWith("/sonarr/") && request.headers["x-api-key"] !== SONARR_API_KEY) {
sendJson(response, 401, { error: "unauthorized" });
return;
}
if (pathname.startsWith("/mealie/") && request.headers.authorization !== `Bearer ${MEALIE_TOKEN}`) {
sendJson(response, 401, { error: "unauthorized" });
return;
}
if (pathname === "/sonarr/api/v3/series") {
sendJson(response, 200, sonarrSeries);
return;
}
if (pathname === "/sonarr/api/v3/queue") {
sendJson(response, 200, sonarrQueue);
return;
}
if (pathname === "/mealie/api/households/mealplans/today") {
sendJson(
response,
200,
mealPlans.filter((plan) => plan.date === BENCHMARK_DATE),
);
return;
}
if (pathname === "/mealie/api/households/mealplans") {
sendJson(response, 200, mealPlansResponse(url));
return;
}
if (pathname === "/openlibrary/search.json") {
sendJson(response, 200, openLibraryResponse(url));
return;
}
if (pathname === "/openmeteo/v1/search") {
const result = geocodingResponse(url);
sendJson(response, result.status, result.body);
return;
}
if (pathname === "/openmeteo/v1/forecast") {
const result = forecastResponse(url);
sendJson(response, result.status, result.body);
return;
}
sendJson(response, 404, { error: "not found" });
});
server.listen(PORT, HOST, () => {
process.stdout.write(`assistant benchmark fixture listening at http://${HOST}:${PORT}\n`);
});