+12








fcd7c979df
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>
622 lines
20 KiB
JavaScript
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`);
|
|
});
|