Merge remote-tracking branch 'origin/dev' into fix/three-xs-fixes

# Conflicts:
#	apps/nextjs/src/components/board/items/widget-context-menu.tsx
This commit is contained in:
ajnart committed 2026-08-08 12:07:10 +02:00
commit cc4e11fce4
235 files changed
+13729 -3807

No files matched your search

+3
View File
@@ -9,7 +9,10 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./board-server": "./src/board-server.ts",
"./query-cache": "./src/query-cache.ts",
"./server-settings-server": "./src/server-settings-server.ts",
"./user-server": "./src/user-server.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
+16
View File
@@ -0,0 +1,16 @@
import { cache } from "react";
import { createRscTrpcContext } from "./rsc-context";
import { boardRouter } from "./router/board";
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
export const getBoardByNameAsync = cache(async (name: string) => {
const caller = await createBoardCallerAsync();
return await caller.getBoardByName({ name });
});
export const getHomeBoardAsync = cache(async () => {
const caller = await createBoardCallerAsync();
return await caller.getHomeBoard();
});
+46 -16
View File
@@ -1,22 +1,54 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheDefaultStaleTimeMs = 0;
// RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000;
export const queryCacheDefaultRefetchIntervalMs = 30_000;
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
export const queryCacheBuster = "v2-superjson";
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
let activeQueryCacheBoardId: string | null = null;
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
export const isWidgetDataTrpcPath = (path: string) => {
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
return widgetDataQueryPaths.has(path);
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
// These supporting queries can be numerous on a dashboard. Give them a
// predictable refresh policy instead of refetching all of them after every tab
// focus/reconnect event. Metadata can refresh after a bounded stale window;
// Docker state remains live on a single shared timer.
export const dashboardSupportingQueryPolicies = [
{
queryKey: [["app", "byId"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["app", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["integration", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["docker", "getContainers"]],
refetchInterval: queryCacheDefaultRefetchIntervalMs,
staleTime: queryCacheDefaultStaleTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
] as const;
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
@@ -26,11 +58,9 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
return isWidgetDataTrpcPath(path.join("."));
};
-1
View File
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+1 -1
View File
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
},
})
.mutation(async ({ ctx, input }) => {
+10 -7
View File
@@ -1757,9 +1757,12 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
};
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
});
const groupPermissionWhere = userId
? inArray(
boardGroupPermissions.groupId,
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, userId)),
)
: eq(boardGroupPermissions.groupId, "");
const board = await db.query.boards.findFirst({
where,
with: {
@@ -1782,8 +1785,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
items: {
with: {
integrations: {
with: {
integration: true,
columns: {
integrationId: true,
},
},
layouts: true,
@@ -1797,7 +1800,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
},
},
groupPermissions: {
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
where: groupPermissionWhere,
},
},
});
@@ -1845,7 +1848,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
integrationIds: itemIntegrations.map((item) => item.integrationId),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
@@ -19,9 +19,12 @@ export const throwIfActionForbiddenAsync = async (
permission: BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const groupPermissionWhere = session
? inArray(
boardGroupPermissions.groupId,
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
)
: eq(boardGroupPermissions.groupId, "");
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
@@ -34,7 +37,7 @@ export const throwIfActionForbiddenAsync = async (
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
where: groupPermissionWhere,
},
},
});
-42
View File
@@ -1,42 +0,0 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
import { createTRPCRouter, internalProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheBoardInput = z.object({
boardId: z.string().min(1),
});
const queryCacheSetInput = queryCacheBoardInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
return {
stored: await setQueryCacheAsync(
userId,
input.boardId,
input.value,
queryCacheDefaultGcTimeMs,
queryCacheMaxValueBytes,
),
};
}),
removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
await removeQueryCacheAsync(userId, input.boardId);
}),
});
@@ -0,0 +1,58 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { updateCheckerRouter } from "../update-checker";
const mocks = vi.hoisted(() => ({
getDataAsync: vi.fn(async () => ({
data: { availableUpdates: [{ tagName: "v99.0.0" }] },
timestamp: new Date(),
})),
}));
vi.mock("@homarr/request-handler/update-checker", () => ({
updateCheckerRequestHandler: {
handler: () => ({ getDataAsync: mocks.getDataAsync }),
},
}));
const createSession = (permissions: GroupPermissionKey[]): Session => ({
user: {
id: "user",
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const createCaller = (session: Session | null) =>
updateCheckerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session,
});
describe("update checker authorization", () => {
test("allows administrators to read the cached result", async () => {
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([
{ tagName: "v99.0.0" },
]);
});
test("rejects authenticated users without admin permission", async () => {
await expect(createCaller(createSession([])).getAvailableUpdates()).rejects.toThrow("Permission denied");
});
test("rejects anonymous users", async () => {
await expect(createCaller(null).getAvailableUpdates()).rejects.toThrow("UNAUTHORIZED");
});
test("returns an empty result when the cache reader fails", async () => {
mocks.getDataAsync.mockRejectedValueOnce(new Error("Redis unavailable"));
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([]);
});
});
+2 -2
View File
@@ -12,8 +12,8 @@ export const updateCheckerRouter = createTRPCRouter({
const data = await handler.getDataAsync();
return data.data.availableUpdates;
} catch (error) {
logger.error(new Error("Failed to get available updates", { cause: error }));
return undefined; // We return undefined to not show the indicator in the UI
logger.error(new Error("Failed to read the cached update check", { cause: error }));
return []; // An empty list hides the indicator without violating TanStack Query's data contract.
}
}),
});
@@ -2,6 +2,7 @@ import SuperJSON from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { env } from "@homarr/common/env";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { items, users } from "@homarr/db/schema";
@@ -20,6 +21,8 @@ const feedsInput = z.object({
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
: await restrictUrlsAsync(ctx.db, input.urls);
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -11,6 +12,8 @@ const atLocationInput = z.object({
export const weatherRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const handler = weatherRequestHandler.handler(input);
return await handler.getDataAsync().then((result) => result.data);
}),
+16
View File
@@ -0,0 +1,16 @@
import { cache } from "react";
import { headers } from "next/headers";
import { auth } from "@homarr/auth/next";
import { createTRPCContext } from "./trpc";
export const createRscTrpcContext = cache(async () => {
const requestHeaders = new Headers(await headers());
requestHeaders.set("x-trpc-source", "rsc");
return createTRPCContext({
session: await auth(),
headers: requestHeaders,
});
});
@@ -0,0 +1,8 @@
import { cache } from "react";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
// One settings snapshot per RSC request, shared by the root layout, theme, and
// widget prefetching. React invalidates this cache between requests.
export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db));
+3 -12
View File
@@ -1,25 +1,16 @@
import { cache } from "react";
import { headers } from "next/headers";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
import { auth } from "@homarr/auth/next";
import { appRouter, createCaller } from "@homarr/api";
import { createRscTrpcContext } from "./rsc-context";
import { makeQueryClient } from "./shared";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
session: await auth(),
headers: heads,
});
});
const createContext = () => createRscTrpcContext();
export const api = createCaller(createContext);
+54 -119
View File
@@ -1,65 +1,20 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
dashboardSupportingQueryPolicies,
isWidgetDataQueryKey,
isWidgetDataTrpcPath,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheMetadataStaleTimeMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, string>();
return {
values,
setQueryCacheAsync: vi.fn(
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
values.set(`${_userId}:${_boardId}`, value);
return true;
},
),
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
values.delete(`${_userId}:${_boardId}`);
}),
};
});
vi.mock("@homarr/redis", () => ({
setQueryCacheAsync: redisMock.setQueryCacheAsync,
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.setQueryCacheAsync.mockClear();
redisMock.removeQueryCacheAsync.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
describe("isWidgetDataQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
@@ -72,77 +27,22 @@ describe("isPersistableWidgetQueryKey", () => {
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
])("matches widget data queries for %j", (queryKey, expected) => {
expect(isWidgetDataQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
stored: true,
});
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
session.user.id,
"board-1",
"cached-value",
expect.any(Number),
queryCacheMaxValueBytes,
);
await caller.removeItem({ boardId: "board-1" });
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.setQueryCacheAsync.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", value: "cached-value" });
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
"anonymous",
"board-1",
"cached-value",
expect.any(Number),
queryCacheMaxValueBytes,
);
});
test("rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
stored: false,
});
describe("isWidgetDataTrpcPath", () => {
test.each([
["widget.calendar.findAllEvents", true],
["app.byIds", true],
["integration.byIds", true],
["docker.getContainers", true],
["widget.app.ping", false],
["widget.beszel.getSystemStats", false],
["board.getBoardByName", false],
])("classifies dashboard data path %s", (path, expected) => {
expect(isWidgetDataTrpcPath(path)).toBe(expected);
});
});
@@ -176,3 +76,38 @@ describe("query cache stale handling", () => {
expect(queryFn).toHaveBeenCalledOnce();
});
});
describe("dashboard supporting query policies", () => {
test("bounds metadata staleness without adding per-widget timers", () => {
expect(dashboardSupportingQueryPolicies).toEqual([
{
queryKey: [["app", "byId"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["app", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["integration", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["docker", "getContainers"]],
refetchInterval: queryCacheDefaultRefetchIntervalMs,
staleTime: queryCacheDefaultStaleTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
]);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { cache } from "react";
import { db, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
// Keep the authenticated user lookup shared across nested RSC layouts. React
// clears this memo between requests, so authorization changes cannot leak.
export const getRscUserSettingsAsync = cache(async (userId: string) =>
db.query.users.findFirst({
columns: {
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, userId),
}),
);
+13 -16
View File
@@ -3,24 +3,18 @@ import type { NextAuthConfig } from "next-auth";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { eq } from "@homarr/db";
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
import { getPermissionsWithChildren } from "@homarr/definitions";
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
if (groupIds.length === 0) return [];
const dbGroupPermissions = await db
.selectDistinct({
permission: groupPermissions.permission,
})
.from(groupPermissions)
.where(inArray(groupPermissions.groupId, groupIds));
.innerJoin(groupMembers, eq(groupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, userId));
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys);
@@ -43,12 +37,15 @@ export const createSessionAsync = async (
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
const additionalProperties = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
});
const [additionalProperties, permissions] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
}),
getCurrentUserPermissionsAsync(db, user.id),
]);
return {
...session,
@@ -57,7 +54,7 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
...additionalProperties,
id: user.id,
name: user.name,
permissions: await getCurrentUserPermissionsAsync(db, user.id),
permissions,
},
};
};
@@ -3,14 +3,9 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationContextProps {
integrations: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -6,31 +6,28 @@ import { groupMembers, integrationGroupPermissions, integrationUserPermissions }
import { constructIntegrationPermissions } from "./integration-permissions";
export const getIntegrationsWithPermissionsAsync = async (session: Session | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrations = await db.query.integrations.findMany({
const groupPermissionWhere = session
? inArray(
integrationGroupPermissions.groupId,
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
)
: eq(integrationGroupPermissions.groupId, "");
const integrationRows = await db.query.integrations.findMany({
columns: {
id: true,
name: true,
url: true,
kind: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
where: groupPermissionWhere,
},
},
});
return integrations.map(({ userPermissions, groupPermissions, ...integration }) => ({
...integration,
return integrationRows.map(({ id, userPermissions, groupPermissions }) => ({
id,
permissions: constructIntegrationPermissions({ userPermissions, groupPermissions }, session),
}));
};
-11
View File
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -36,16 +35,6 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
return (
<BoardContext.Provider
value={{
+6 -2
View File
@@ -35,8 +35,10 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
db.transaction((transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
// oxlint-disable-next-line import/namespace -- TableKey limits key to exported schema tables.
const table = schema[key];
transaction
.insert(schema[key])
.insert(table)
.values(values as never)
.run();
}
@@ -50,7 +52,9 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
// Below is actually the mysqlSchema when the driver is mysql
await transaction.insert(schema[key] as never).values(values as never);
// oxlint-disable-next-line import/namespace -- TableKey limits key to exported schema tables.
const table = schema[key] as never;
await transaction.insert(table).values(values as never);
}
}
});
+1
View File
@@ -9,6 +9,7 @@
"exports": {
".": "./index.ts",
"./collection": "./collection.ts",
"./proxy-reader": "./proxy-reader.ts",
"./schema": "./schema/index.ts",
"./test": "./test/index.ts",
"./queries": "./queries/index.ts",
@@ -0,0 +1,128 @@
// @vitest-environment node
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { MySqlContainer } from "@testcontainers/mysql";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import mysql from "mysql2/promise";
import { Client } from "pg";
import { stringify } from "superjson";
import { describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dbEnv: {} as Record<string, unknown>,
}));
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv }));
const configureDriver = (values: Record<string, unknown>) => {
vi.resetModules();
for (const key of Object.keys(mocks.dbEnv)) delete mocks.dbEnv[key];
Object.assign(mocks.dbEnv, values);
};
const readSettingsAsync = async () => {
const reader = await import("./proxy-reader");
const result = {
locale: await reader.getDefaultLocaleForProxyAsync(),
onboardingStep: await reader.getOnboardingStepForProxyAsync(),
};
await reader.closeProxySettingsReaderAsync();
return result;
};
describe("proxy settings reader database compatibility", () => {
test("reads SQLite settings and falls back for malformed culture data", async () => {
const databasePath = path.join(os.tmpdir(), `homarr-proxy-reader-${process.pid}-${Date.now()}.sqlite`);
const database = new Database(databasePath);
database.exec("CREATE TABLE onboarding (step TEXT NOT NULL)");
database.exec('CREATE TABLE "serverSetting" (setting_key TEXT NOT NULL, value TEXT NOT NULL)');
database.prepare("INSERT INTO onboarding (step) VALUES (?)").run("finish");
database
.prepare('INSERT INTO "serverSetting" (setting_key, value) VALUES (?, ?)')
.run("culture", stringify({ defaultLocale: "de" }));
database.close();
try {
configureDriver({ DRIVER: "better-sqlite3", URL: databasePath });
await expect(readSettingsAsync()).resolves.toEqual({ locale: "de", onboardingStep: "finish" });
const malformedDatabase = new Database(databasePath);
malformedDatabase.prepare('UPDATE "serverSetting" SET value = ? WHERE setting_key = ?').run("{", "culture");
malformedDatabase.close();
vi.resetModules();
await expect(readSettingsAsync()).resolves.toEqual({ locale: "en", onboardingStep: "finish" });
} finally {
await fs.rm(databasePath, { force: true });
}
});
test("reads MySQL settings through the bounded host configuration", async () => {
const container = await new MySqlContainer("mysql:latest").start();
try {
const connection = await mysql.createConnection({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getUserPassword(),
});
await connection.execute("CREATE TABLE onboarding (step VARCHAR(32) NOT NULL)");
await connection.execute("CREATE TABLE serverSetting (setting_key VARCHAR(64) NOT NULL, value TEXT NOT NULL)");
await connection.execute("INSERT INTO onboarding (step) VALUES (?)", ["finish"]);
await connection.execute("INSERT INTO serverSetting (setting_key, value) VALUES (?, ?)", [
"culture",
stringify({ defaultLocale: "fr" }),
]);
await connection.end();
configureDriver({
DRIVER: "mysql2",
HOST: container.getHost(),
PORT: container.getPort(),
NAME: container.getDatabase(),
USER: container.getUsername(),
PASSWORD: container.getUserPassword(),
});
await expect(readSettingsAsync()).resolves.toEqual({ locale: "fr", onboardingStep: "finish" });
} finally {
await container.stop();
}
}, 120_000);
test("reads PostgreSQL settings through the bounded host configuration", async () => {
const container = await new PostgreSqlContainer("postgres:latest").start();
try {
const connection = new Client({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
});
await connection.connect();
await connection.query("CREATE TABLE onboarding (step TEXT NOT NULL)");
await connection.query('CREATE TABLE "serverSetting" (setting_key TEXT NOT NULL, value TEXT NOT NULL)');
await connection.query("INSERT INTO onboarding (step) VALUES ($1)", ["finish"]);
await connection.query('INSERT INTO "serverSetting" (setting_key, value) VALUES ($1, $2)', [
"culture",
stringify({ defaultLocale: "es" }),
]);
await connection.end();
configureDriver({
DRIVER: "node-postgres",
HOST: container.getHost(),
PORT: container.getPort(),
NAME: container.getDatabase(),
USER: container.getUsername(),
PASSWORD: container.getPassword(),
});
await expect(readSettingsAsync()).resolves.toEqual({ locale: "es", onboardingStep: "finish" });
} finally {
await container.stop();
}
}, 120_000);
});
+128
View File
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const query = {
where: vi.fn(),
limit: vi.fn().mockResolvedValue([{ step: "finish" }]),
};
query.where.mockReturnValue(query);
return {
dbEnv: {
DRIVER: "mysql2",
URL: "mysql://homarr:test@localhost:3306/homarr",
} as Record<string, unknown>,
mysqlCreatePool: vi.fn(() => ({})),
postgresPool: vi.fn(function PostgresPool() {
return {};
}),
database: {
select: vi.fn(() => ({ from: vi.fn(() => query) })),
},
};
});
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv }));
vi.mock("mysql2", () => ({ default: { createPool: mocks.mysqlCreatePool } }));
vi.mock("pg", () => ({ Pool: mocks.postgresPool }));
vi.mock("drizzle-orm/mysql2", () => ({ drizzle: vi.fn(() => mocks.database) }));
vi.mock("drizzle-orm/node-postgres", () => ({ drizzle: vi.fn(() => mocks.database) }));
vi.mock("./proxy/mysql", () => ({
proxySchema: { onboarding: { step: {} }, serverSettings: { settingKey: {}, value: {} } },
}));
vi.mock("./proxy/postgresql", () => ({
proxySchema: { onboarding: { step: {} }, serverSettings: { settingKey: {}, value: {} } },
}));
const initializeProxyReader = async () => {
const { getOnboardingStepForProxyAsync } = await import("./proxy-reader");
await getOnboardingStepForProxyAsync();
};
describe("proxy database pools", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
for (const key of Object.keys(mocks.dbEnv)) delete mocks.dbEnv[key];
mocks.mysqlCreatePool.mockReturnValue({});
});
it("bounds MySQL URL pools instead of falling back to the driver defaults", async () => {
Object.assign(mocks.dbEnv, {
DRIVER: "mysql2",
URL: "mysql://homarr:test@localhost:3306/homarr",
});
await initializeProxyReader();
expect(mocks.mysqlCreatePool).toHaveBeenCalledWith({
uri: "mysql://homarr:test@localhost:3306/homarr",
connectionLimit: 1,
maxIdle: 1,
idleTimeout: 60_000,
enableKeepAlive: true,
});
});
it("bounds MySQL host pools instead of falling back to the driver defaults", async () => {
Object.assign(mocks.dbEnv, {
DRIVER: "mysql2",
HOST: "localhost",
PORT: 3306,
NAME: "homarr",
USER: "homarr",
PASSWORD: "test",
});
await initializeProxyReader();
expect(mocks.mysqlCreatePool).toHaveBeenCalledWith(expect.objectContaining({ connectionLimit: 1, maxIdle: 1 }));
});
it("bounds PostgreSQL URL pools instead of falling back to the driver defaults", async () => {
Object.assign(mocks.dbEnv, {
DRIVER: "node-postgres",
URL: "postgresql://homarr:test@localhost:5432/homarr",
});
await initializeProxyReader();
expect(mocks.postgresPool).toHaveBeenCalledWith({
connectionString: "postgresql://homarr:test@localhost:5432/homarr",
max: 1,
idleTimeoutMillis: 60_000,
allowExitOnIdle: false,
});
});
it("bounds PostgreSQL host pools instead of falling back to the driver defaults", async () => {
Object.assign(mocks.dbEnv, {
DRIVER: "node-postgres",
HOST: "localhost",
PORT: 5432,
NAME: "homarr",
USER: "homarr",
PASSWORD: "test",
});
await initializeProxyReader();
expect(mocks.postgresPool).toHaveBeenCalledWith(expect.objectContaining({ max: 1 }));
});
it("retries initialization after a transient driver failure", async () => {
Object.assign(mocks.dbEnv, {
DRIVER: "mysql2",
URL: "mysql://homarr:test@localhost:3306/homarr",
});
mocks.mysqlCreatePool.mockImplementationOnce(() => {
throw new Error("database unavailable");
});
const { getOnboardingStepForProxyAsync } = await import("./proxy-reader");
await expect(getOnboardingStepForProxyAsync()).rejects.toThrow("database unavailable");
await expect(getOnboardingStepForProxyAsync()).resolves.toBe("finish");
expect(mocks.mysqlCreatePool).toHaveBeenCalledTimes(2);
});
});
+173
View File
@@ -0,0 +1,173 @@
import { parse as parseSuperJson } from "superjson";
import { eq } from "drizzle-orm";
import type { SQLWrapper } from "drizzle-orm";
import { defaultServerSettings } from "@homarr/server-settings";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
type ProxyRow = Record<string, unknown>;
type ProxySchema = {
onboarding: { step: SQLWrapper };
serverSettings: { settingKey: SQLWrapper; value: SQLWrapper };
};
type ProxyQuery = {
where(condition: unknown): ProxyQuery;
limit(count: number): Promise<ProxyRow[]>;
};
type ProxyDatabase = {
select(selection: Record<string, unknown>): { from(table: unknown): ProxyQuery };
};
type ProxyReader = {
closeAsync: () => Promise<void>;
getOnboardingStepAsync: () => Promise<string>;
getDefaultLocaleAsync: () => Promise<string>;
};
// The proxy is long-lived and only performs two single-row reads. Keep its
// independent pool deliberately smaller than the application pool.
const proxyPoolConnectionLimit = 1;
const createProxyReader = (database: unknown, schema: ProxySchema, closeAsync: () => Promise<void>): ProxyReader => {
const queryDatabase = database as ProxyDatabase;
return {
closeAsync,
async getOnboardingStepAsync() {
const rows = await queryDatabase.select({ step: schema.onboarding.step }).from(schema.onboarding).limit(1);
const step = rows[0]?.step;
return typeof step === "string" ? step : "start";
},
async getDefaultLocaleAsync() {
const rows = await queryDatabase
.select({ value: schema.serverSettings.value })
.from(schema.serverSettings)
.where(eq(schema.serverSettings.settingKey, "culture"))
.limit(1);
const value = rows[0]?.value;
if (typeof value !== "string") return defaultServerSettings.culture.defaultLocale;
try {
const culture: unknown = parseSuperJson(value);
if (
typeof culture === "object" &&
culture !== null &&
"defaultLocale" in culture &&
typeof culture.defaultLocale === "string"
) {
return culture.defaultLocale;
}
} catch {
// Fall through to the default when a legacy or malformed value cannot be decoded.
}
return defaultServerSettings.culture.defaultLocale;
},
};
};
const createProxyReaderAsync = async (): Promise<ProxyReader> => {
switch (dbEnv.DRIVER) {
case "better-sqlite3": {
const [{ default: Database }, { drizzle }, { proxySchema }] = await Promise.all([
import("better-sqlite3"),
import("drizzle-orm/better-sqlite3"),
import("./proxy/sqlite"),
]);
const connection = new Database(dbEnv.URL);
const database = drizzle(connection, { schema: proxySchema });
return createProxyReader(database, proxySchema, async () => {
connection.close();
});
}
case "mysql2": {
const [{ default: mysql }, { drizzle }, { proxySchema }] = await Promise.all([
import("mysql2"),
import("drizzle-orm/mysql2"),
import("./proxy/mysql"),
]);
const connection = dbEnv.HOST
? mysql.createPool({
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
connectionLimit: proxyPoolConnectionLimit,
maxIdle: 1,
idleTimeout: 60_000,
enableKeepAlive: true,
})
: mysql.createPool({
uri: dbEnv.URL,
connectionLimit: proxyPoolConnectionLimit,
maxIdle: 1,
idleTimeout: 60_000,
enableKeepAlive: true,
});
const database = drizzle(connection, { schema: proxySchema, mode: "default" });
return createProxyReader(
database,
proxySchema,
async () =>
await new Promise<void>((resolve, reject) => {
connection.end((error) => {
if (error) reject(error);
else resolve();
});
}),
);
}
case "node-postgres": {
const [{ Pool }, { drizzle }, { proxySchema }] = await Promise.all([
import("pg"),
import("drizzle-orm/node-postgres"),
import("./proxy/postgresql"),
]);
const connection = dbEnv.HOST
? new Pool({
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
max: proxyPoolConnectionLimit,
idleTimeoutMillis: 60_000,
allowExitOnIdle: false,
})
: new Pool({
connectionString: dbEnv.URL,
max: proxyPoolConnectionLimit,
idleTimeoutMillis: 60_000,
allowExitOnIdle: false,
});
const database = drizzle({ client: connection, schema: proxySchema });
return createProxyReader(database, proxySchema, async () => {
await connection.end();
});
}
}
};
let proxyReaderPromise: Promise<ProxyReader> | null = null;
const getProxyReaderAsync = () => {
if (!proxyReaderPromise) {
proxyReaderPromise = createProxyReaderAsync().catch((error: unknown) => {
proxyReaderPromise = null;
throw error;
});
}
return proxyReaderPromise;
};
export const getOnboardingStepForProxyAsync = async () => (await getProxyReaderAsync()).getOnboardingStepAsync();
export const getDefaultLocaleForProxyAsync = async () => (await getProxyReaderAsync()).getDefaultLocaleAsync();
export const closeProxySettingsReaderAsync = async () => {
if (!proxyReaderPromise) return;
const reader = await proxyReaderPromise;
proxyReaderPromise = null;
await reader.closeAsync();
};
+11
View File
@@ -0,0 +1,11 @@
import { mysqlTable, text, varchar } from "drizzle-orm/mysql-core";
export const proxySchema = {
onboarding: mysqlTable("onboarding", {
step: varchar("step", { length: 32 }).notNull(),
}),
serverSettings: mysqlTable("serverSetting", {
settingKey: varchar("setting_key", { length: 64 }).notNull(),
value: text("value").notNull(),
}),
};
+11
View File
@@ -0,0 +1,11 @@
import { pgTable, text } from "drizzle-orm/pg-core";
export const proxySchema = {
onboarding: pgTable("onboarding", {
step: text("step").notNull(),
}),
serverSettings: pgTable("serverSetting", {
settingKey: text("setting_key").notNull(),
value: text("value").notNull(),
}),
};
+11
View File
@@ -0,0 +1,11 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
export const proxySchema = {
onboarding: sqliteTable("onboarding", {
step: text("step").notNull(),
}),
serverSettings: sqliteTable("serverSetting", {
settingKey: text("setting_key").notNull(),
value: text("value").notNull(),
}),
};
+3
View File
@@ -63,6 +63,7 @@ test("schemas should match", () => {
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
const mysqlTable = mysqlSchema[tableName];
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
@@ -84,6 +85,7 @@ test("schemas should match", () => {
).toEqual(mysqlColumn.primary);
});
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
const mysqlTable = mysqlSchema[tableName];
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
@@ -144,6 +146,7 @@ test("schemas should match for postgresql", () => {
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
// skipcq: JS-E1007
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
const postgresqlTable = postgresqlSchema[tableName];
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
+1 -1
View File
@@ -4,5 +4,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
"exclude": ["node_modules", "migrations/*/migrate.cjs"]
}
+1
View File
@@ -13,6 +13,7 @@
},
"exports": {
".": "./index.ts",
"./cookie": "./src/cookie.ts",
"./docs/*": "./src/docs/*.ts"
},
"scripts": {
@@ -27,7 +27,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"],
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
coolify: ["coolify"],
@@ -40,7 +40,13 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
umami: ["umami"],
vpn: getIntegrationKindsByCategory("vpn"),
ups: getIntegrationKindsByCategory("ups"),
beszelSystemTable: ["beszel", "mock"],
beszelSystemGrid: ["beszel", "mock"],
beszelAlerts: ["beszel", "mock"],
beszelSystemStats: ["beszel", "mock"],
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
@@ -185,9 +185,15 @@ export class OverseerrIntegration
}),
);
return settled
const fulfilled = settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
if (fulfilled.length === 0) {
throw new Error("Failed to resolve any media request information");
}
return fulfilled;
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
+1 -3
View File
@@ -6,9 +6,7 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
setQueryCacheAsync,
getQueryCacheAsync,
removeQueryCacheAsync,
createLockChannel,
invalidateIntegrationCacheAsync,
} from "./lib/channel";
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
vi.mock("./connection", () => ({
createRedisConnection: () => null,
}));
import { createLockChannel } from "./channel";
describe("createLockChannel", () => {
test("degrades gracefully when Redis connections are disabled", async () => {
const lock = createLockChannel("test-lock");
const token = await lock.acquireAsync(60);
expect(token).toEqual(expect.any(String));
if (!token) throw new Error("Expected the fallback lock to return a token");
await expect(lock.releaseAsync(token)).resolves.toBeUndefined();
});
});
+29 -29
View File
@@ -1,13 +1,10 @@
import superjson from "superjson";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
import { createRedisConnection } from "./connection";
const logger = createLogger({ module: "redisChannel" });
const publisher = createRedisConnection();
const lastDataClient = createRedisConnection();
@@ -114,10 +111,11 @@ export const createGetSetChannel = <TData>(name: string) => {
* @param options optional TTL in seconds
*/
setAsync: async (data: TData, options?: { ttlSeconds?: number }) => {
await getSetClient.set(name, superjson.stringify(data));
if (options?.ttlSeconds) {
await getSetClient.expire(name, options.ttlSeconds);
await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds);
return;
}
await getSetClient.set(name, superjson.stringify(data));
},
/**
* Remove data from the channel
@@ -128,32 +126,34 @@ export const createGetSetChannel = <TData>(name: string) => {
};
};
const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
/**
* Creates a short-lived distributed lock.
*
* The token prevents a process from releasing a lock that expired and was
* acquired by another process in the meantime.
*/
export const createLockChannel = (name: string) => {
return {
acquireAsync: async (ttlSeconds: number) => {
const token = createId();
const client = getSetClient as typeof getSetClient | null;
if (!client) return token;
export const setQueryCacheAsync = async (
userId: string,
boardId: string,
value: string,
ttlMs: number,
maxValueBytes: number,
) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
logger.warn("Query cache value exceeded maximum size", {
key: queryCacheKey(userId, boardId),
valueBytes: Buffer.byteLength(value, "utf8"),
maxValueBytes,
});
return false;
}
await getSetClient.set(queryCacheKey(userId, boardId), value, "PX", ttlMs);
return true;
};
const result = await client.set(name, token, "EX", ttlSeconds, "NX");
return result === "OK" ? token : null;
},
releaseAsync: async (token: string) => {
const client = getSetClient as typeof getSetClient | null;
if (!client) return;
export const getQueryCacheAsync = async (userId: string, boardId: string) =>
await getSetClient.get(queryCacheKey(userId, boardId));
export const removeQueryCacheAsync = async (userId: string, boardId: string) => {
await getSetClient.del(queryCacheKey(userId, boardId));
await client.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1,
name,
token,
);
},
};
};
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
@@ -14,6 +14,7 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
export const createIntegrationRequestHandler = <
@@ -23,24 +24,18 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const integrationMap = new Map<string, IntegrationOfKind<TKind>>();
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
requestAsync: async (input) => {
const integration = integrationMap.get(input.integrationId);
if (!integration) {
throw new Error(`Integration ${input.integrationId} not found in cache`);
}
return options.requestAsync(integration, input.options);
},
const inner = createRequestHandler<TData, { integration: IntegrationOfKind<TKind>; options: TInput }>({
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
getCacheKey: ({ integration, options: itemOptions }) =>
JSON.stringify({ integrationId: integration.id, itemOptions }),
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
});
return {
invalidateCache: inner.invalidateCache,
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
integrationMap.set(integration.id, integration);
return inner.handler({ integrationId: integration.id, options: itemOptions });
},
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
inner.handler({ integration, options: itemOptions }),
};
};
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import { createIntegrationRequestHandler } from "./integration-request-handler";
import { createRequestHandler } from "./request-handler";
import { createWidgetRequestHandler } from "./widget-request-handler";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const createIntegration = (secret: string) =>
({
id: "integration-id",
kind: "sonarr",
decryptedSecrets: [{ kind: "apiKey", value: secret }],
}) as Omit<Integration, "kind"> & {
kind: "sonarr";
decryptedSecrets: Pick<IntegrationSecret, "kind" | "value">[];
externalUrl: string | null;
};
afterEach(() => {
vi.useRealTimers();
});
describe("createRequestHandler", () => {
it("deduplicates in-flight work and caches completed data", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createRequestHandler({
requestAsync: async () => {
calls += 1;
return await request.promise;
},
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(1);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "value" });
expect(calls).toBe(1);
});
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
let calls = 0;
const request = deferred<{ call: number }>();
const handler = createRequestHandler({
cacheTtlMs: 0,
requestAsync: async () => {
calls += 1;
if (calls === 1) return await request.promise;
return { call: calls };
},
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(1);
request.resolve({ call: 1 });
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: { call: 1 } }, { data: { call: 1 } }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
expect(calls).toBe(2);
});
it("releases positive-ttl entries after expiry without another request", async () => {
vi.useFakeTimers();
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 25,
requestAsync: async () => ({ call: ++calls }),
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 1 } });
await vi.advanceTimersByTimeAsync(25);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
expect(calls).toBe(2);
});
it("cancels scheduled expiry work when the cache is invalidated", async () => {
vi.useFakeTimers();
const handler = createRequestHandler({ cacheTtlMs: 25, requestAsync: async () => "value" });
await handler.handler({ id: "same" }).getDataAsync();
expect(vi.getTimerCount()).toBe(1);
handler.invalidateCache();
expect(vi.getTimerCount()).toBe(0);
});
it("bounds stale-on-error fallback and releases it while idle", async () => {
vi.useFakeTimers();
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 25,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 50,
requestAsync: async () => {
calls += 1;
if (calls === 1) return "last-known-good";
throw new Error("upstream unavailable");
},
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
data: "last-known-good",
});
await vi.advanceTimersByTimeAsync(25);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
data: "last-known-good",
});
await vi.advanceTimersByTimeAsync(51);
await expect(handler.handler({ id: "same" }).getDataAsync()).rejects.toThrow("upstream unavailable");
expect(calls).toBe(3);
});
it("reschedules staggered stale entries by their retirement deadline without spinning", async () => {
vi.useFakeTimers();
const handler = createRequestHandler({
cacheTtlMs: 10,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 100,
requestAsync: async ({ id }: { id: string }) => id,
});
await handler.handler({ id: "first" }).getDataAsync();
await vi.advanceTimersByTimeAsync(20);
await handler.handler({ id: "second" }).getDataAsync();
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(90);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(20);
expect(vi.getTimerCount()).toBe(0);
});
it("evicts an empty-string cache key at the size limit", async () => {
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 60_000,
getCacheKey: ({ id }: { id: string }) => id,
requestAsync: async () => ++calls,
});
await handler.handler({ id: "" }).getDataAsync();
for (let index = 0; index < 1000; index += 1) {
await handler.handler({ id: String(index) }).getDataAsync();
}
await handler.handler({ id: "" }).getDataAsync();
expect(calls).toBe(1002);
});
it("does not let a pre-invalidation request repopulate or clear newer work", async () => {
const staleRequest = deferred<string>();
const freshRequest = deferred<string>();
const requests = [staleRequest, freshRequest];
let calls = 0;
const handler = createRequestHandler({
requestAsync: async () => {
const request = requests[calls++];
if (!request) throw new Error("Unexpected request");
return await request.promise;
},
});
const stale = handler.handler({ id: "same" }).getDataAsync();
handler.invalidateCache();
const fresh = handler.handler({ id: "same" }).getDataAsync();
staleRequest.resolve("stale");
await expect(stale).resolves.toMatchObject({ data: "stale" });
const joinsFresh = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(2);
freshRequest.resolve("fresh");
await expect(Promise.all([fresh, joinsFresh])).resolves.toMatchObject([{ data: "fresh" }, { data: "fresh" }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "fresh" });
expect(calls).toBe(2);
});
});
describe("createIntegrationRequestHandler", () => {
it("keys requests by integration id and options without retaining secret values", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
requestAsync: async () => {
calls += 1;
return await request.promise;
},
});
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
expect(calls).toBe(1);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
});
});
describe("createWidgetRequestHandler", () => {
it("shares its cache across calls while keeping distinct inputs separate", async () => {
let calls = 0;
const handler = createWidgetRequestHandler({ requestAsync: async ({ id }: { id: string }) => `${id}-${++calls}` });
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" });
expect(calls).toBe(2);
});
});
@@ -1,18 +1,21 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
getCacheKey?: (input: TInput) => string;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now();
for (const [key, entry] of cache) {
if (now >= entry.expiresAt) cache.delete(key);
if (now >= entry.staleUntil) cache.delete(key);
}
};
@@ -21,44 +24,98 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
) => {
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
let expiryTimerAt: number | undefined;
let generation = 0;
const scheduleExpiry = (candidateExpiryAt?: number) => {
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
if (cache.size === 0) return;
const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil));
expiryTimerAt = nextExpiryAt;
expiryTimer = setTimeout(
() => {
expiryTimer = undefined;
expiryTimerAt = undefined;
evictExpired(cache);
scheduleExpiry();
},
Math.max(0, nextExpiryAt - Date.now()),
);
expiryTimer.unref?.();
};
return {
invalidateCache: () => {
generation += 1;
cache.clear();
inflight.clear();
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
},
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
const existing = inflight.get(key);
if (existing) return existing;
const promise = options
const requestGeneration = generation;
let promise: Promise<CacheEntry<TData>>;
promise = options
.requestAsync(input)
.then((data) => {
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
const expiresAt = Date.now() + ttl;
const staleIfErrorTtlMs = options.fallbackToStaleOnError
? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
: 0;
const entry: CacheEntry<TData> = {
data,
timestamp: new Date(),
expiresAt,
staleUntil: expiresAt + staleIfErrorTtlMs,
};
if (ttl > 0 && generation === requestGeneration) {
evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
}
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry);
inflight.delete(key);
return entry;
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
if (
options.fallbackToStaleOnError &&
cached &&
Date.now() < cached.staleUntil &&
generation === requestGeneration
) {
return cached;
}
throw err;
})
.finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
@@ -6,9 +6,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (widgetOptions: TInput) =>
createRequestHandler<TData, TInput>({
requestAsync: options.requestAsync,
}).handler(widgetOptions),
});
) => {
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
return inner;
};
@@ -0,0 +1,244 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => {
type StoredValue = {
expiresAt: number | null;
value: unknown;
};
const values = new Map<string, StoredValue>();
const locks = new Set<string>();
const listReleases = vi.fn();
const redisOperation = vi.fn();
return {
env: { NO_EXTERNAL_CONNECTION: false },
listReleases,
redisOperation,
values,
locks,
removeFreshResult() {
for (const key of values.keys()) {
if (key.startsWith("update-checker:fresh:")) values.delete(key);
}
},
};
});
vi.mock("@homarr/common/env", () => ({ env: mocks.env }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock("@homarr/redis", () => ({
createGetSetChannel: (name: string) => ({
getAsync: async () => {
mocks.redisOperation();
const stored = mocks.values.get(name);
if (!stored) return null;
if (stored.expiresAt !== null && Date.now() >= stored.expiresAt) {
mocks.values.delete(name);
return null;
}
return stored.value;
},
setAsync: async (value: unknown, options?: { ttlSeconds?: number }) => {
mocks.redisOperation();
mocks.values.set(name, {
value,
expiresAt: options?.ttlSeconds ? Date.now() + options.ttlSeconds * 1_000 : null,
});
},
removeAsync: async () => {
mocks.redisOperation();
mocks.values.delete(name);
},
}),
createLockChannel: (name: string) => ({
acquireAsync: async () => {
mocks.redisOperation();
if (mocks.locks.has(name)) return null;
mocks.locks.add(name);
return "test-lock-token";
},
releaseAsync: async () => {
mocks.redisOperation();
mocks.locks.delete(name);
},
}),
}));
vi.mock("octokit", () => ({
Octokit: class {
rest = {
repos: {
listReleases: mocks.listReleases,
},
};
},
}));
const successfulResponse = {
data: [
{
body_html: undefined,
html_url: "https://github.com/homarr-labs/homarr/releases/tag/v99.0.0",
name: "v99.0.0",
prerelease: false,
tag_name: "v99.0.0",
},
],
headers: {},
status: 200,
url: "",
};
const loadHandlerAsync = async () => {
const { updateCheckerRequestHandler } = await import("../update-checker");
return updateCheckerRequestHandler.handler({});
};
describe("persisted update checker cache", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
mocks.env.NO_EXTERNAL_CONNECTION = false;
mocks.listReleases.mockReset();
mocks.redisOperation.mockReset();
mocks.values.clear();
mocks.locks.clear();
});
test("checks GitHub once for repeated page loads inside 24 hours", async () => {
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
const first = await handler.getDataAsync();
const second = await handler.getDataAsync();
expect(first.data).toEqual(second.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("deduplicates concurrent checks", async () => {
mocks.listReleases.mockImplementation(async () => {
await Promise.resolve();
return successfulResponse;
});
const handler = await loadHandlerAsync();
const results = await Promise.all(Array.from({ length: 10 }, async () => await handler.getDataAsync()));
expect(results.every(({ data }) => data.availableUpdates.length === 1)).toBe(true);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("deduplicates concurrent checks across simulated processes", async () => {
let completeGitHubRequest: (() => void) | undefined;
mocks.listReleases.mockImplementation(
async () =>
await new Promise<typeof successfulResponse>((resolve) => {
completeGitHubRequest = () => resolve(successfulResponse);
}),
);
const firstHandler = await loadHandlerAsync();
vi.resetModules();
const secondHandler = await loadHandlerAsync();
const firstRequest = firstHandler.getDataAsync();
await vi.waitFor(() => expect(mocks.listReleases).toHaveBeenCalledOnce());
const secondRequest = secondHandler.getDataAsync();
completeGitHubRequest?.();
const results = await Promise.all([firstRequest, secondRequest]);
expect(results[0].data).toEqual(results[1].data);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("refreshes at the 24-hour boundary, not before it", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
await handler.getDataAsync();
now += 24 * 60 * 60 * 1_000 - 1;
await handler.getDataAsync();
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
now += 1;
await handler.getDataAsync();
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
});
test("reuses the Redis result after a simulated process restart", async () => {
mocks.listReleases.mockResolvedValue(successfulResponse);
const firstHandler = await loadHandlerAsync();
await firstHandler.getDataAsync();
vi.resetModules();
const restartedHandler = await loadHandlerAsync();
await restartedHandler.getDataAsync();
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("ignores update results created for an older application version", async () => {
mocks.values.set("update-checker:fresh:v1", {
value: { availableUpdates: [], attemptedAt: 1, checkedAt: 1 },
expiresAt: null,
});
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
const result = await handler.getDataAsync();
expect(result.data.availableUpdates).toHaveLength(1);
expect(mocks.listReleases).toHaveBeenCalledOnce();
});
test("serves stale data and retries only after 24 hours following a provider failure", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
mocks.listReleases.mockResolvedValueOnce(successfulResponse).mockRejectedValue(new Error("GitHub unavailable"));
const handler = await loadHandlerAsync();
const fresh = await handler.getDataAsync();
mocks.removeFreshResult();
const stale = await handler.getDataAsync();
now += 24 * 60 * 60 * 1_000 - 1;
const repeated = await handler.getDataAsync();
expect(stale.data).toEqual(fresh.data);
expect(repeated.data).toEqual(fresh.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
now += 1;
const retried = await handler.getDataAsync();
expect(retried.data).toEqual(fresh.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(3);
});
test("does not contact GitHub when external connections are disabled", async () => {
mocks.env.NO_EXTERNAL_CONNECTION = true;
const handler = await loadHandlerAsync();
await expect(handler.getDataAsync()).resolves.toMatchObject({ data: { availableUpdates: [] } });
expect(mocks.listReleases).not.toHaveBeenCalled();
expect(mocks.redisOperation).not.toHaveBeenCalled();
});
});
+102 -7
View File
@@ -5,19 +5,114 @@ import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createRequestHandler } from "./lib/request-handler";
import { createGetSetChannel, createLockChannel } from "@homarr/redis";
import packageJson from "../../../package.json";
const logger = createLogger({ module: "updateCheckerRequestHandler" });
export const updateCheckerRequestHandler = createRequestHandler({
async requestAsync(_) {
const updateCheckTtlSeconds = 24 * 60 * 60;
const updateCheckLockTtlSeconds = 2 * 60;
const updateCheckWaitIntervalMs = 100;
const updateCheckWaitAttempts = 50;
type UpdateCheckCacheEntry = {
availableUpdates: Update[];
attemptedAt: number;
checkedAt: number | null;
};
const updateCheckCacheVersion = `v2:${packageJson.version}`;
const freshUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
`update-checker:fresh:${updateCheckCacheVersion}`,
);
const staleUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
`update-checker:stale:${updateCheckCacheVersion}`,
);
const updateCheckLock = createLockChannel(`update-checker:lock:${updateCheckCacheVersion}`);
const waitAsync = async (durationMs: number) =>
await new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
});
const waitForConcurrentUpdateCheckAsync = async () => {
for (let attempt = 0; attempt < updateCheckWaitAttempts; attempt++) {
await waitAsync(updateCheckWaitIntervalMs);
const cached = await freshUpdateCheckChannel.getAsync();
if (cached) return cached;
}
return await staleUpdateCheckChannel.getAsync();
};
const getCachedAvailableUpdatesAsync = async (): Promise<UpdateCheckCacheEntry> => {
if (env.NO_EXTERNAL_CONNECTION) {
return {
availableUpdates: [],
attemptedAt: Date.now(),
checkedAt: null,
};
}
const cached = await freshUpdateCheckChannel.getAsync();
if (cached) return cached;
const staleBeforeRefresh = await staleUpdateCheckChannel.getAsync();
const lockToken = await updateCheckLock.acquireAsync(updateCheckLockTtlSeconds);
if (!lockToken) {
if (staleBeforeRefresh) return staleBeforeRefresh;
const concurrentResult = await waitForConcurrentUpdateCheckAsync();
if (concurrentResult) return concurrentResult;
throw new Error("Timed out waiting for the update check");
}
try {
const attemptedAt = Date.now();
const availableUpdates = await getAvailableUpdatesAsync(packageJson.version);
return { availableUpdates };
},
cacheTtlMs: 30 * 60 * 1000,
});
const result: UpdateCheckCacheEntry = {
availableUpdates,
attemptedAt,
checkedAt: attemptedAt,
};
await staleUpdateCheckChannel.setAsync(result);
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
return result;
} catch (error) {
const stale = await staleUpdateCheckChannel.getAsync();
const result: UpdateCheckCacheEntry = {
availableUpdates: stale?.availableUpdates ?? [],
attemptedAt: Date.now(),
checkedAt: stale?.checkedAt ?? null,
};
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
logger.warn("Failed to fetch available updates; suppressing retries for 24 hours", {
error: error instanceof Error ? error.message : String(error),
servingStale: stale !== null,
});
return result;
} finally {
try {
await updateCheckLock.releaseAsync(lockToken);
} catch (error) {
logger.warn("Failed to release the update-check lock", {
error: error instanceof Error ? error.message : String(error),
});
}
}
};
export const updateCheckerRequestHandler = {
handler: (_input: Record<string, never>) => ({
getDataAsync: async () => {
const result = await getCachedAvailableUpdatesAsync();
return {
data: { availableUpdates: result.availableUpdates },
timestamp: new Date(result.checkedAt ?? result.attemptedAt),
};
},
}),
};
interface Update {
name: string | null;
+2
View File
@@ -13,6 +13,8 @@
},
"exports": {
".": "./index.ts",
"./component": "./src/component.ts",
"./open": "./src/open.ts",
"./styles.css": "./src/styles.css"
},
"scripts": {
+3
View File
@@ -0,0 +1,3 @@
"use client";
export { Spotlight } from "./components/spotlight";
+30 -11
View File
@@ -11,18 +11,18 @@ import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { OpenMediaRequestSearchOptions } from "../index";
import {
consumePendingMediaRequestSearch,
consumePendingSpotlightOpen,
mediaRequestSearchEvent,
spotlightOpenEvent,
} from "../open";
import type { OpenMediaRequestSearchOptions } from "../open";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
import {
mediaRequestSearchEvent,
mediaRequestSearchScopeAtom,
selectAction,
spotlightActions,
spotlightStore,
} from "../spotlight-store";
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -64,6 +64,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
useEffect(() => {
const handleOpen = () => {
consumePendingSpotlightOpen();
spotlightActions.open();
};
window.addEventListener(spotlightOpenEvent, handleOpen);
if (consumePendingSpotlightOpen()) spotlightActions.open();
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
}, []);
const clearChildrenStack = useCallback(() => {
setChildrenStack([]);
}, []);
@@ -84,17 +95,24 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
}, [setQuery]);
useEffect(() => {
const handleMediaRequestSearch = (event: Event) => {
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => {
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
setQuery(query ?? "");
setQuery(searchQuery ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
};
const handleMediaRequestSearch = (event: Event) => {
consumePendingMediaRequestSearch();
const { integrationIds, query: searchQuery } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
applyMediaRequestSearch({ integrationIds, query: searchQuery });
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
const pendingSearch = consumePendingMediaRequestSearch();
if (pendingSearch) applyMediaRequestSearch(pendingSearch);
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
@@ -134,6 +152,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
store={spotlightStore}
>
<MantineSpotlight.Search
data-homarr-dev-benchmark-spotlight
placeholder={`${t("search.placeholder")}...`}
ref={inputRef}
leftSectionWidth={hasModeCharacter ? 80 : 48}
+1 -21
View File
@@ -1,28 +1,8 @@
"use client";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch };
export { openSpotlight, openMediaRequestSearch, mediaRequestSearchEvent } from "./open";
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions,
} from "./modes/home/context";
const openSpotlight = spotlightActions.open;
export interface OpenMediaRequestSearchOptions {
integrationIds?: string[];
query?: string;
}
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
}
spotlightActions.open();
};
export { mediaRequestSearchEvent };
+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
consumePendingMediaRequestSearch,
consumePendingSpotlightOpen,
hasPendingSpotlightOpen,
mediaRequestSearchEvent,
openMediaRequestSearch,
openSpotlight,
spotlightOpenEvent,
} from "./open";
describe("lazy Spotlight open bridge", () => {
beforeEach(() => {
consumePendingSpotlightOpen();
consumePendingMediaRequestSearch();
});
it("keeps a pre-mount open request until the lazy component consumes it", () => {
const listener = vi.fn();
window.addEventListener(spotlightOpenEvent, listener);
openSpotlight();
expect(listener).toHaveBeenCalledOnce();
expect(consumePendingSpotlightOpen()).toBe(true);
expect(consumePendingSpotlightOpen()).toBe(false);
window.removeEventListener(spotlightOpenEvent, listener);
});
it("lets a listener installed after the request discover the pending open", () => {
openSpotlight();
expect(hasPendingSpotlightOpen()).toBe(true);
expect(consumePendingSpotlightOpen()).toBe(true);
expect(hasPendingSpotlightOpen()).toBe(false);
});
it("retains media options while dispatching both mount and media events", () => {
const openListener = vi.fn();
const mediaListener = vi.fn();
window.addEventListener(spotlightOpenEvent, openListener);
window.addEventListener(mediaRequestSearchEvent, mediaListener);
openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
expect(openListener).toHaveBeenCalledOnce();
expect(mediaListener).toHaveBeenCalledOnce();
expect(consumePendingSpotlightOpen()).toBe(true);
expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
window.removeEventListener(spotlightOpenEvent, openListener);
window.removeEventListener(mediaRequestSearchEvent, mediaListener);
});
});
+44
View File
@@ -0,0 +1,44 @@
"use client";
export const spotlightOpenEvent = "homarr:spotlight:open";
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export interface OpenMediaRequestSearchOptions {
integrationIds?: string[];
query?: string;
}
let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
let pendingSpotlightOpen = false;
const requestSpotlightMount = () => {
if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent));
};
export const openSpotlight = () => {
pendingSpotlightOpen = true;
requestSpotlightMount();
};
export const consumePendingSpotlightOpen = () => {
const pending = pendingSpotlightOpen;
pendingSpotlightOpen = false;
return pending;
};
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen;
export const consumePendingMediaRequestSearch = () => {
const pending = pendingMediaRequestSearch;
pendingMediaRequestSearch = null;
return pending;
};
export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
pendingMediaRequestSearch = options;
openSpotlight();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
}
};
@@ -12,7 +12,6 @@ export interface MediaRequestSearchScope {
}
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
+1
View File
@@ -16,6 +16,7 @@
"./client": "./src/client/index.ts",
"./server": "./src/server.ts",
"./middleware": "./src/middleware.ts",
"./languages": "./src/languages.ts",
"./request": "./src/request.ts",
"./dayjs": "./src/dayjs.ts"
},
+5 -8
View File
@@ -15,13 +15,10 @@ declare module "next-intl" {
}
}
export const { useI18n, useScopedI18n } = {
useI18n: useTranslations,
useScopedI18n: useTranslations,
};
export const { useI18nMessages } = {
useI18nMessages: () => useMessages(),
};
// Keep these as direct exports so Turbopack and webpack can statically inspect
// the client package's public surface.
export const useI18n = useTranslations;
export const useScopedI18n = useTranslations;
export const useI18nMessages = () => useMessages();
export { useTranslations };
+5 -5
View File
@@ -1,6 +1,9 @@
import type { MRT_Localization } from "mantine-react-table";
import { objectKeys } from "@homarr/common";
import type { SupportedLanguage } from "./languages";
export { supportedLanguages } from "./languages";
export type { SupportedLanguage } from "./languages";
export const localeConfigurations = {
ca: {
@@ -433,10 +436,7 @@ export type LanguageIconDefinition =
url: string;
};
export const supportedLanguages = objectKeys(localeConfigurations);
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const fallbackLocale = "en" satisfies SupportedLanguage;
export { fallbackLocale } from "./languages";
export const isLocaleRTL = (locale: SupportedLanguage) =>
"isRTL" in localeConfigurations[locale] && localeConfigurations[locale].isRTL;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SupportedLanguage } from "./config";
import { supportedLanguages } from "./config";
import type { SupportedLanguage } from "./languages";
import { supportedLanguages } from "./languages";
import type { stringOrTranslation, TranslationFunction } from "./type";
export * from "./type";
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "S'està reproduint",
"user": "Usuari",
@@ -2734,6 +2757,9 @@
"id": "ID",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Elimina",
"confirm": "",
"notification": {
"success": {
"title": "Contenidors eliminats",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "倒序"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "获取容器统计信息失败"
"internalServerError": "获取容器统计信息失败",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "只显示当前正在播放",
"description": "禁用此功能对plex不起作用"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "正在播放",
"user": "用户",
@@ -2734,6 +2757,9 @@
"id": "ID",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "显示歌曲",
"description": "显示音乐库中歌曲的总数 (Navidrome)"
},
"showNowPlaying": {
"label": "显示正在播放",
"description": "显示当前播放的曲目 (Navidrome)"
},
"showNowPlayingList": {
"label": "显示正在播放列表",
"description": "显示单个曲目而不仅仅是活跃流计数 (Navidrome)"
},
"maxNowPlayingItems": {
"label": "最大正在播放项目数",
"description": "正在播放列表中显示的最大曲目数 (Navidrome)"
},
"showLibraryCount": {
"label": "显示库数量",
"description": "显示实例中的库数量 (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "播客",
"listeningTime": "收听时长",
"activeSessions": "活跃会话",
"nowPlaying": "正在播放",
"nowPlayingCount": "{count, plural, other {# 个流}}",
"error": {
"internalServerError": "获取音频统计数据失败"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "显示代理版本"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "未找到系统",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "系统",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "删除",
"confirm": "",
"notification": {
"success": {
"title": "容器已删除",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "crwdns3820:0crwdne3820:0"
},
"columnOrder": {
"label": "crwdns6155:0crwdne6155:0"
},
"columnWidths": {
"label": "crwdns6157:0crwdne6157:0"
}
},
"error": {
"internalServerError": "crwdns3428:0crwdne3428:0"
"internalServerError": "crwdns3428:0crwdne3428:0",
"layoutSaveTitle": "crwdns6159:0crwdne6159:0",
"layoutSaveMessage": "crwdns6161:0crwdne6161:0"
},
"empty": {
"noContainers": "crwdns6163:0crwdne6163:0"
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "crwdns3176:0crwdne3176:0",
"description": "crwdns3178:0crwdne3178:0"
},
"showBitrate": {
"label": "crwdns6175:0crwdne6175:0",
"description": "crwdns6177:0crwdne6177:0"
},
"showLocation": {
"label": "crwdns6179:0crwdne6179:0",
"description": "crwdns6181:0crwdne6181:0"
}
},
"footer": {
"streams": "crwdns6183:0count={count}crwdne6183:0",
"totalBitrate": "crwdns6185:0{bitrate}crwdne6185:0"
},
"items": {
"currentlyPlaying": "crwdns2847:0crwdne2847:0",
"user": "crwdns2476:0crwdne2476:0",
@@ -2734,6 +2757,9 @@
"id": "crwdns2480:0crwdne2480:0",
"status": "crwdns6085:0crwdne6085:0",
"directPlay": "crwdns6087:0crwdne6087:0",
"directStream": "crwdns6187:0crwdne6187:0",
"transcodeVideo": "crwdns6189:0crwdne6189:0",
"transcodeAudio": "crwdns6191:0crwdne6191:0",
"transcoding": "crwdns6089:0crwdne6089:0",
"paused": "crwdns6091:0crwdne6091:0",
"remaining": "crwdns6093:0{minutes}crwdne6093:0",
@@ -3768,18 +3794,6 @@
"label": "crwdns5109:0crwdne5109:0",
"description": "crwdns5111:0crwdne5111:0"
},
"showNowPlaying": {
"label": "crwdns5113:0crwdne5113:0",
"description": "crwdns5115:0crwdne5115:0"
},
"showNowPlayingList": {
"label": "crwdns5117:0crwdne5117:0",
"description": "crwdns5119:0crwdne5119:0"
},
"maxNowPlayingItems": {
"label": "crwdns5121:0crwdne5121:0",
"description": "crwdns5123:0crwdne5123:0"
},
"showLibraryCount": {
"label": "crwdns5125:0crwdne5125:0",
"description": "crwdns5127:0crwdne5127:0"
@@ -3813,8 +3827,6 @@
"podcasts": "crwdns5159:0crwdne5159:0",
"listeningTime": "crwdns5161:0crwdne5161:0",
"activeSessions": "crwdns5163:0crwdne5163:0",
"nowPlaying": "crwdns5165:0crwdne5165:0",
"nowPlayingCount": "crwdns5167:0count={count}crwdne5167:0",
"error": {
"internalServerError": "crwdns5169:0crwdne5169:0"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "crwdns4809:0crwdne4809:0"
},
"columnOrder": {
"label": "crwdns6165:0crwdne6165:0"
},
"columnWidths": {
"label": "crwdns6167:0crwdne6167:0"
}
},
"noRecords": "crwdns4811:0crwdne4811:0",
"error": {
"internalServerError": "crwdns5651:0crwdne5651:0"
"internalServerError": "crwdns5651:0crwdne5651:0",
"layoutSaveTitle": "crwdns6169:0crwdne6169:0",
"layoutSaveMessage": "crwdns6171:0crwdne6171:0"
},
"column": {
"system": "crwdns4813:0crwdne4813:0",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "crwdns1952:0crwdne1952:0",
"confirm": "crwdns6173:0crwdne6173:0",
"notification": {
"success": {
"title": "crwdns1954:0crwdne1954:0",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "Uživatel",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Zobrazit verzi agenta"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nenalezeny žádné systémy",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Systém",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Odstranit",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Inverter sortering"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Kunne ikke hente containerstatistik"
"internalServerError": "Kunne ikke hente containerstatistik",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Vis kun hvad der aktuelt afspilles",
"description": "Deaktivering af dette vil ikke virke for plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Afspiller lige nu",
"user": "Bruger",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Vis agentversion"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Ingen systemer fundet",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "System",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Fjern",
"confirm": "",
"notification": {
"success": {
"title": "Containere fjernet",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Aktuelle Wiedergabe",
"user": "Benutzer",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Entfernen",
"confirm": "",
"notification": {
"success": {
"title": "Container entfernt",
+61 -40
View File
@@ -1342,19 +1342,19 @@
},
"traefik": {
"name": "Traefik",
"description": ""
"description": "Cloud-nativer Reverse-Proxy und Lastverteiler"
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
},
"patchmon": {
"name": "",
"description": ""
"name": "PatchMon",
"description": "Patch-Verwaltung und Systemüberwachung für Linux-Hosts"
},
"bazarr": {
"name": "Bazarr",
"description": ""
"description": "Schwesteranwendung zu Sonarr und Radarr, welche Untertitel und Downloads verwaltet"
}
},
"media": {
@@ -2328,8 +2328,8 @@
"label": "Dateisystem Info anzeigen"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"label": "Sichtbare Speichervolumes",
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ",
"placeholder": "Speichervolumen auswählen"
},
"defaultTab": {
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Sortierung umkehren"
},
"columnOrder": {
"label": "Spaltenordnung"
},
"columnWidths": {
"label": "Spaltenbreite"
}
},
"error": {
"internalServerError": "Fehler beim Abrufen der Container Statistiken"
"internalServerError": "Fehler beim Abrufen der Container Statistiken",
"layoutSaveTitle": "Tabellenlayout nicht gespeichert",
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. "
},
"empty": {
"noContainers": "Keine Docker-Container gefunden"
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Nur aktuelle Wiedergabe anzeigen",
"description": "Diese Einstellung zu deaktivieren wird für Plex nicht funktionieren"
},
"showBitrate": {
"label": "Zeigt die Bitrate an",
"description": ""
},
"showLocation": {
"label": "",
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
}
},
"footer": {
"streams": "",
"totalBitrate": "Gesamtbitrate: {bitrate}"
},
"items": {
"currentlyPlaying": "Aktuelle Wiedergabe",
"user": "Benutzer",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "Status",
"directPlay": "Direkte Wiedergabe",
"directStream": "Direkter Stream",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "Transkodierung",
"paused": "Pausiert",
"remaining": "{minutes} Min. verbleibend",
@@ -2803,21 +2829,21 @@
}
},
"patchmon": {
"name": "",
"name": "PatchMon",
"description": "",
"title": "",
"title": "PatchMon-Statistiken",
"totalHosts": "Hosts gesamt",
"hostsNeedingUpdates": "Update erforderlich",
"securityUpdates": "Sicherheits-Updates",
"upToDateHosts": "",
"upToDateHosts": "Aktualisierte Hosts",
"hostsWithSecurityUpdates": "Hosts mit Sicherheits-Updates",
"recentUpdates24h": "Updates (24h)",
"totalOutdatedPackages": "Veraltete Pakete",
"totalRepos": "Repositories",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"osDistribution": "Betriebssystem-Distribution",
"compliance": "Patch-Kompatibilität",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Zuletzt aktualisiert: {time}",
"option": {
"showTotalHosts": {
"label": "Alle Hosts anzeigen",
@@ -2833,30 +2859,30 @@
},
"showUpToDateHosts": {
"label": "Zeige aktuelle Hosts",
"description": ""
"description": "Zeigt Hosts mit keinen veralteten Paketen an"
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
"label": "Zeigt Hosts mit Sicherheitsupdates an",
"description": "Zeigt Hosts an, welche Sicherheitsupdates benötigen"
},
"showRecentUpdates24h": {
"label": "",
"description": ""
"label": "Zeigt vergangene Updates in den letzten 24 Stunden an",
"description": "Zeigt die Anzahl der angewendeten Updates in den letzten 24 Stunden an"
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
"label": "Zeigt veraltete Pakete an",
"description": "Zeigt die Gesamtanzahl der veralteten Pakete über mehrere Hosts verteilt an"
},
"showTotalRepos": {
"label": "",
"description": ""
"label": "Zeigt Repositorys an",
"description": "Zeigt die Anzahl der zu überwachenden aktiven Repositorys an"
},
"enableThresholdColors": {
"label": "",
"label": "Aktiviert die Schwellwert-Farben",
"description": ""
},
"useCustomThresholds": {
"label": "",
"label": "Erweiterte Schwellwerte",
"description": ""
},
"thresholdMode": {
@@ -3768,18 +3794,6 @@
"label": "Songs anzeigen",
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
},
"showNowPlaying": {
"label": "\"Sie hören\" anzeigen",
"description": "Zeige aktuell gespielte Titel an (Navidrome)"
},
"showNowPlayingList": {
"label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen",
"description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Maximale jetzt abgespielte Elemente",
"description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)"
},
"showLibraryCount": {
"label": "Anzahl der Bibliotheken anzeigen",
"description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcasts",
"listeningTime": "Hördauer",
"activeSessions": "Aktive Sitzungen",
"nowPlaying": "Aktuelle Wiedergabe",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Agent-Version anzeigen"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Keine Systeme gefunden",
"error": {
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen."
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "System",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Entfernen",
"confirm": "",
"notification": {
"success": {
"title": "Container entfernt",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Εμφάνιση τραγουδιών",
"description": "Εμφάνιση του συνολικού αριθμού τραγουδιών στη βιβλιοθήκη μουσικής σας (Navidrome)"
},
"showNowPlaying": {
"label": "Εμφάνιση τρέχουσας αναπαραγωγής",
"description": "Εμφάνιση κομματιών που αναπαράγονται αυτήν τη στιγμή (Navidrome)"
},
"showNowPlayingList": {
"label": "Εμφάνιση λίστας τρέχουσας αναπαραγωγής",
"description": "Εμφάνιση μεμονωμένων κομματιών αντί μόνο του αριθμού ενεργών ροών (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Μέγιστα στοιχεία τρέχουσας αναπαραγωγής",
"description": "Μέγιστος αριθμός κομματιών προς εμφάνιση στη λίστα τρέχουσας αναπαραγωγής (Navidrome)"
},
"showLibraryCount": {
"label": "Εμφάνιση αριθμού βιβλιοθηκών",
"description": "Εμφάνιση του αριθμού βιβλιοθηκών στην εγκατάστασή σας (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Ποντκαστ",
"listeningTime": "Χρόνος Ακρόασης",
"activeSessions": "Ενεργές Συνεδρίες",
"nowPlaying": "Τρέχουσα Αναπαραγωγή",
"nowPlayingCount": "{count, plural, one {# ροή} other {# ροές}}",
"error": {
"internalServerError": "Αποτυχία λήψης δεδομένων στατιστικών ήχου"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Εμφάνιση έκδοσης agent"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Δεν βρέθηκαν συστήματα",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Σύστημα",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Αφαίρεση",
"confirm": "",
"notification": {
"success": {
"title": "",
+52 -31
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Invert sorting"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Failed to fetch containers stats"
"internalServerError": "Failed to fetch containers stats",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2564,26 +2575,26 @@
"description": "Display visitor stats from your Umami analytics instance",
"visitors": "visitors",
"active": "active",
"pageviews": "",
"visits": "",
"bounceRate": "",
"avgDuration": "",
"pageviews": "pageviews",
"visits": "visits",
"bounceRate": "bounce",
"avgDuration": "avg",
"option": {
"websiteId": {
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noWebsites": "",
"configureIntegrationFirst": ""
"label": "Website",
"description": "Select a website tracked by your Umami instance",
"placeholder": "Select a website",
"loading": "Loading websites...",
"noWebsites": "No websites found",
"configureIntegrationFirst": "Connect a Umami integration first"
},
"timeFrame": {
"label": "",
"description": "",
"label": "Time frame",
"description": "The time period to display visitor data for",
"option": {
"today": "",
"24h": "",
"7d": "",
"today": "Today",
"24h": "Last 24 hours",
"7d": "Last 7 days",
"30d": "Last 30 days",
"month": "This month",
"lastMonth": "Last month"
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Show only currently playing",
"description": "Disabling this will not work for Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Currently playing",
"user": "User",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "Status",
"directPlay": "Direct play",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
@@ -3768,18 +3794,6 @@
"label": "Show songs",
"description": "Show the total number of songs in your music library (Navidrome)"
},
"showNowPlaying": {
"label": "Show now playing",
"description": "Show tracks currently being played (Navidrome)"
},
"showNowPlayingList": {
"label": "Show now playing list",
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Max now playing items",
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
},
"showLibraryCount": {
"label": "Show library count",
"description": "Show the number of libraries on your instance (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcasts",
"listeningTime": "Listening Time",
"activeSessions": "Active Sessions",
"nowPlaying": "Now Playing",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Failed to fetch audio stats data"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Show agent version"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "No systems found",
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "System",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Invertir orden"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "No se pudo obtener las estadísticas de los contenedores"
"internalServerError": "No se pudo obtener las estadísticas de los contenedores",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Mostrar sólo reproducción actual",
"description": "Desactivar esta opción no funcionará para plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Reproducción en curso",
"user": "Usuario",
@@ -2734,6 +2757,9 @@
"id": "ID",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Eliminar",
"confirm": "",
"notification": {
"success": {
"title": "Contenedores eliminados",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Käänteinen järjestys"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "Tila",
"directPlay": "Muuntamaton toisto",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "Transkoodaus",
"paused": "Keskeytetty",
"remaining": "{minutes} min jäljellä",
@@ -3768,18 +3794,6 @@
"label": "Näytä kappaleet",
"description": "Näytä musiikkikirjastosi kappaleiden kokonaismäärä (Navidrome)"
},
"showNowPlaying": {
"label": "Näytä nyt soi",
"description": "Näytä tällä hetkellä toistettavat kappaleet (Navidrome)"
},
"showNowPlayingList": {
"label": "Näytä nyt soiva lista",
"description": "Näytä yksittäiset kappaleet pelkän aktiivisten striimien määrän sijaan (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Nyt soivien kohteiden enimmäismäärä",
"description": "Nyt soivassa listassa näytettävien kappaleiden enimmäismäärä (Navidrome)"
},
"showLibraryCount": {
"label": "Näytä kirjastojen määrä",
"description": "Näytä instanssisi kirjastojen lukumäärä (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcastit",
"listeningTime": "Kuunteluaika",
"activeSessions": "Aktiiviset istunnot",
"nowPlaying": "Nyt soitetaan",
"nowPlayingCount": "{count, plural, one {# striimi} other {# striimiä}}",
"error": {
"internalServerError": "Audiotilastojen nouto epäonnistui"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Näytä agentin versio"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Järjestelmiä ei löytynyt",
"error": {
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi."
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Järjestelmä",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Poista",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Inverser le tri"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Impossible de récupérer les statistiques des conteneurs"
"internalServerError": "Impossible de récupérer les statistiques des conteneurs",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Afficher seulement les médias en cours de lecture",
"description": "Désactiver cette option ne fonctionnera pas pour Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "En cours de lecture",
"user": "Utilisateur",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Afficher les titres",
"description": ""
},
"showNowPlaying": {
"label": "Afficher la lecture en cours",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Supprimer",
"confirm": "",
"notification": {
"success": {
"title": "Conteneurs supprimés",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "מיון הפוך"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "נכשל באחזור סטטיסטיקות המכולות"
"internalServerError": "נכשל באחזור סטטיסטיקות המכולות",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "הצג רק את ההפעלה הנוכחית",
"description": "השבתת אפשרות זו לא תעבוד עבור פלקס"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "מתנגן כרגע",
"user": "משתמש",
@@ -2734,6 +2757,9 @@
"id": "מזהה",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "הסר",
"confirm": "",
"notification": {
"success": {
"title": "הקונטיינרים הוסרו",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Prikaži verziju agenta"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nisu pronađeni sustavi",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sustav",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Ukloni",
"confirm": "",
"notification": {
"success": {
"title": "Kontejneri su uklonjeni",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Fordított rendezés"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "A konténerek statisztikáinak lekérése sikertelen"
"internalServerError": "A konténerek statisztikáinak lekérése sikertelen",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Csak a jelenleg lejátszottakat mutasd",
"description": "Ennek letiltása a Plex esetében nem fog működni"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Jelenleg lejátszás alatt",
"user": "Felhasználó",
@@ -2734,6 +2757,9 @@
"id": "Azonosító",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Zeneszámok megjelenítése",
"description": "A zeneszámok teljes számának megjelenítése a zenekönyvtárban (Navidrome)"
},
"showNowPlaying": {
"label": "Most játszott megjelenítése",
"description": "A jelenleg játszott sávok megjelenítése (Navidrome)"
},
"showNowPlayingList": {
"label": "Most játszott lista megjelenítése",
"description": "Egyes sávok megjelenítése a csak aktív streamszám helyett (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Maximális most játszott elemek",
"description": "A most játszott listában megjelenítendő sávok maximális száma (Navidrome)"
},
"showLibraryCount": {
"label": "Könyvtárak számának megjelenítése",
"description": "A példányodon lévő könyvtárak számának megjelenítése (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcastek",
"listeningTime": "Hallgatási idő",
"activeSessions": "Aktív munkamenetek",
"nowPlaying": "Most játszott",
"nowPlayingCount": "{count, plural, one {# stream} other {# stream}}",
"error": {
"internalServerError": "Az audió statisztikai adatok letöltése nem sikerült"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Agent verzió mutatása"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nincsenek rendszerek",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Rendszer",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Eltávolítás",
"confirm": "",
"notification": {
"success": {
"title": "Konténerek eltávolítva",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Inverti ordinamento"
},
"columnOrder": {
"label": "Ordine delle colonne"
},
"columnWidths": {
"label": "Larghezza delle colonne"
}
},
"error": {
"internalServerError": "Recupero statistiche container non riuscito"
"internalServerError": "Recupero statistiche container non riuscito",
"layoutSaveTitle": "Layout della tabella non salvato",
"layoutSaveMessage": "Impossibile salvare il layout della tabella Docker. Prova a spostare o ridimensionare nuovamente la colonna."
},
"empty": {
"noContainers": "Nessun container trovato"
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Mostra solo attualmente in riproduzione",
"description": "La disabilitazione non funzionerà per Plex"
},
"showBitrate": {
"label": "Mostra bitrate",
"description": "Mostra il bitrate di riproduzione corrente accanto alla posizione"
},
"showLocation": {
"label": "Mostra posizione",
"description": "Mostra se lo stream si trova sulla tua LAN o WAN"
}
},
"footer": {
"streams": "{count, plural, one {# stream attivo} other {# stream attivi}}",
"totalBitrate": "Totale: {bitrate}"
},
"items": {
"currentlyPlaying": "Attualmente in riproduzione",
"user": "Utente",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "Stato",
"directPlay": "Riproduzione diretta",
"directStream": "Streaming diretto",
"transcodeVideo": "Transcodifica video",
"transcodeAudio": "Transcodifica audio",
"transcoding": "Transcodifica",
"paused": "In pausa",
"remaining": "{minutes} min rimanenti",
@@ -3768,18 +3794,6 @@
"label": "Mostra brani",
"description": "Mostra il numero totale di brani nella tua libreria musicale (Navidrome)"
},
"showNowPlaying": {
"label": "Mostra in riproduzione",
"description": "Mostra le tracce attualmente in riproduzione (Navidrome)"
},
"showNowPlayingList": {
"label": "Mostra elenco in riproduzione",
"description": "Mostra le singole tracce invece del solo conteggio degli stream attivi (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Numero massimo elementi in riproduzione",
"description": "Numero massimo di tracce da mostrare nell'elenco in riproduzione (Navidrome)"
},
"showLibraryCount": {
"label": "Mostra conteggio librerie",
"description": "Mostra il numero di librerie sulla tua istanza (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcast",
"listeningTime": "Tempo di Ascolto",
"activeSessions": "Sessioni Attive",
"nowPlaying": "In Riproduzione",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Impossibile recuperare i dati delle statistiche audio"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Mostra versione agente"
},
"columnOrder": {
"label": "Ordine delle colonne"
},
"columnWidths": {
"label": "Larghezza delle colonne"
}
},
"noRecords": "Nessun sistema trovato",
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.",
"layoutSaveTitle": "Impossibile salvare il layout della tabella",
"layoutSaveMessage": "Impossibile salvare l'ordine o la larghezza delle colonne. Riprova."
},
"column": {
"system": "Sistema",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Rimuovi",
"confirm": "Fare clic di nuovo per rimuovere definitivamente",
"notification": {
"success": {
"title": "Containers rimossi",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "並び替えを反転"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "コンテナステータスの取得に失敗しました"
"internalServerError": "コンテナステータスの取得に失敗しました",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "現在再生中のみ表示",
"description": "これを無効にすると plex では動作しません"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "再生中の曲",
"user": "ユーザー",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "削除",
"confirm": "",
"notification": {
"success": {
"title": "コンテナー削除",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "역순 정렬"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "컨테이너 상태 불러오기 실패"
"internalServerError": "컨테이너 상태 불러오기 실패",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "현재 재생 중 항목만 표시",
"description": "비활성화 옵션은 plex에서는 지원되지 않음"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "현재 재생 중",
"user": "사용자",
@@ -2734,6 +2757,9 @@
"id": "아이디",
"status": "상태",
"directPlay": "직접 재생",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "트랜스코딩",
"paused": "일시 중지됨",
"remaining": "남은 시간 {minutes}분",
@@ -3768,18 +3794,6 @@
"label": "음원 표시",
"description": "음악 라이브러리에 있는 음원 총 수를 표시 (Navidrome)"
},
"showNowPlaying": {
"label": "현재 재생 중 표시",
"description": "현재 재생 중인 트랙을 표시 (Navidrome)"
},
"showNowPlayingList": {
"label": "현재 재생 목록 표시",
"description": "활성 스트림 수 대신 개별 트랙 표시 (Navidrome)"
},
"maxNowPlayingItems": {
"label": "최대 현재 재생 항목 수",
"description": "현재 재생 목록에 표시할 최대 트랙 수 (Navidrome)"
},
"showLibraryCount": {
"label": "라이브러리 수 표시",
"description": "인스턴스에 있는 라이브러리 수 표시 (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "팟캐스트",
"listeningTime": "청취 시간",
"activeSessions": "활성 세션",
"nowPlaying": "현재 재생 중",
"nowPlayingCount": "{count, plural, one {# 스트림} other {# 스트림}}",
"error": {
"internalServerError": "오디오 정보 가져오기 실패"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "에이전트 버전 표시"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "시스템을 찾을 수 없음",
"error": {
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오."
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "시스템",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "제거",
"confirm": "",
"notification": {
"success": {
"title": "컨테이너 제거됨",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Rodyti dainas",
"description": "Rodyti bendrą dainų skaičių savo muzikos bibliotekoje (Navidrome)"
},
"showNowPlaying": {
"label": "Rodyti dabar grojamą",
"description": "Rodyti šiuo metu grojamas dainas (Navidrome)"
},
"showNowPlayingList": {
"label": "Rodyti dabar grojamų sąrašą",
"description": "Rodyti atskiras dainas vietoj tik aktyvių srautų skaičiaus (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Maks. dabar grojamų elementų",
"description": "Didžiausias rodomų dainų skaičius dabar grojamų sąraše (Navidrome)"
},
"showLibraryCount": {
"label": "Rodyti bibliotekų skaičių",
"description": "Rodyti bibliotekų skaičių savo egzemplioriuje (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Rodyti agento versiją"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Sistemų nerasta",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sistema",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Pašalinti",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Rādīt dziesmas",
"description": "Parādīt kopējo dziesmu skaitu mūzikas bibliotēkā (Navidrome)"
},
"showNowPlaying": {
"label": "Rādīt pašlaik atskaņoto",
"description": "Parādīt šobrīd atskaņotās dziesmas (Navidrome)"
},
"showNowPlayingList": {
"label": "Rādīt pašlaik atskaņoto sarakstu",
"description": "Rādīt atsevišķas dziesmas, nevis tikai aktīvo straumju skaitu (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Maks. pašlaik atskaņoto vienumu skaits",
"description": "Maksimālais dziesmu skaits, ko rādīt pašlaik atskaņotajā sarakstā (Navidrome)"
},
"showLibraryCount": {
"label": "Rādīt bibliotēku skaitu",
"description": "Parādīt bibliotēku skaitu jūsu instancē (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podkāsti",
"listeningTime": "Klausīšanās laiks",
"activeSessions": "Aktīvās sesijas",
"nowPlaying": "Atskaņo pašlaik",
"nowPlayingCount": "{count, plural, zero {# straumju} one {# straume} other {# straumes}}",
"error": {
"internalServerError": "Neizdevās iegūt audio statistikas datus"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Rādīt aģenta versiju"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nav atrasta neviena sistēma",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sistēma",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Noņemt",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Sorteren omkeren"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Ophalen van containerstatistieken mislukt"
"internalServerError": "Ophalen van containerstatistieken mislukt",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Alleen huidig spelende weergeven",
"description": "Dit uitschakelen werkt niet voor Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Momenteel aan het afspelen",
"user": "Gebruiker",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Toon agentversie"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Geen systemen gevonden",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Systeem",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Verwijderen",
"confirm": "",
"notification": {
"success": {
"title": "Containers verwijderd",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Reverser sortering"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Kunne ikke hente containerstatistikk"
"internalServerError": "Kunne ikke hente containerstatistikk",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Vis kun det som spiller nå",
"description": "Å deaktivere dette fungerer ikke for Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Spilles nå",
"user": "Bruker",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Fjern",
"confirm": "",
"notification": {
"success": {
"title": "Containere fjernet",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Odwróć sortowanie"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Nie udało się pobrać statystyk kontenerów"
"internalServerError": "Nie udało się pobrać statystyk kontenerów",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Pokaż tylko aktualnie odtwarzane",
"description": "Wyłączenie tej opcji nie zadziała w przypadku Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Obecnie odtwarzane",
"user": "Użytkownik",
@@ -2734,6 +2757,9 @@
"id": "ID",
"status": "Status",
"directPlay": "Odtwarzanie bezpośrednie",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "Transkodowanie",
"paused": "Zatrzymane",
"remaining": "{minutes} min pozostało",
@@ -3768,18 +3794,6 @@
"label": "Pokaż utwory",
"description": "Pokaż całkowitą liczbę utworów w twojej bibliotece muzycznej (Navidrome)"
},
"showNowPlaying": {
"label": "Pokaż teraz odtwarzane",
"description": "Pokaż aktualnie odtwarzane utwory (Navidrome)"
},
"showNowPlayingList": {
"label": "Pokaż listę teraz odtwarzanych",
"description": "Pokaż poszczególne utwory zamiast tylko aktywnej liczby strumieni (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Maksymalna ilość teraz odtwarzanych elementów",
"description": "Maksymalna liczba elementów wyświetlanych na liście teraz odtwarzanych (Navidrome)"
},
"showLibraryCount": {
"label": "Pokaż ilość bibliotek",
"description": "Pokaż liczbę bibliotek na twojej instancji (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcasty",
"listeningTime": "Czas Słuchania",
"activeSessions": "Aktywne Sesje",
"nowPlaying": "Teraz Odtwarzane",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Nie udało się pobrać danych audio stats"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Pokaż wersję agenta"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nie znaleziono systemów",
"error": {
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji."
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "System",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Usuń",
"confirm": "",
"notification": {
"success": {
"title": "Usunięto kontenery",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Inverter ordenação"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Erro ao obter as estatísticas dos contêineres"
"internalServerError": "Erro ao obter as estatísticas dos contêineres",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Exibir apenas o que está sendo reproduzido",
"description": "Desabilitar isso não funcionará para o Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Reproduzindo agora",
"user": "Usuário",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Mostrar músicas",
"description": "Mostrar o número total de músicas na sua biblioteca de música (Navidrome)"
},
"showNowPlaying": {
"label": "Mostrar tocando agora",
"description": "Mostrar as faixas que estão sendo reproduzidas no momento (Navidrome)"
},
"showNowPlayingList": {
"label": "Mostrar lista de reprodução atual",
"description": "Mostrar faixas individuais em vez de apenas a contagem de streams ativos (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Máximo de itens em reprodução",
"description": "Número máximo de faixas para mostrar na lista de reprodução atual (Navidrome)"
},
"showLibraryCount": {
"label": "Mostrar contagem de bibliotecas",
"description": "Mostrar o número de bibliotecas na sua instância (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcasts",
"listeningTime": "Tempo de audição",
"activeSessions": "Sessões ativas",
"nowPlaying": "Tocando agora",
"nowPlayingCount": "{count, plural, one {# transmissão} other {# transmissões}}",
"error": {
"internalServerError": "Falha ao buscar dados de estatísticas de áudio"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Mostrar versão do agente"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nenhum sistema encontrado",
"error": {
"internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração."
"internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sistema",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Excluir",
"confirm": "",
"notification": {
"success": {
"title": "Contêineres removidos",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Exibir versão do agente"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nenhum sistema encontrado",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sistema",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Remover",
"confirm": "",
"notification": {
"success": {
"title": "Contêineres removidos",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Arată versiunea agentului"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Niciun sistem găsit",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sistem",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Elimină",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Обратная сортировка"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Не удалось получить статистику контейнеров"
"internalServerError": "Не удалось получить статистику контейнеров",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Показывать только воспроизводимое",
"description": "Отключение этой опции не работает для Plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Сейчас воспроизводится",
"user": "Пользователь",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Показать версию агента"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Системы не найдены",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Система",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Удалить",
"confirm": "",
"notification": {
"success": {
"title": "Контейнеры удалены",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov"
"internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Zobraziť len aktuálne prehrávanú",
"description": "Zakázanie tohto nebude fungovať pre plex"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Momentálne sa prehráva",
"user": "Používateľ",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "Zobraziť skladby",
"description": "Zobraziť celkový počet skladieb vo vašej hudobnej knižnici (Navidrome)"
},
"showNowPlaying": {
"label": "Zobraziť práve hrané",
"description": "Zobraziť skladby, ktoré sa práve prehrávajú (Navidrome)"
},
"showNowPlayingList": {
"label": "Zobraziť zoznam práve hraných",
"description": "Zobraziť jednotlivé skladby namiesto len počtu aktívnych streamov (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Max. počet položiek práve hraných",
"description": "Maximálny počet skladieb zobrazených v zozname práve hraných (Navidrome)"
},
"showLibraryCount": {
"label": "Zobraziť počet knižníc",
"description": "Zobraziť počet knižníc na vašej inštancii (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcasty",
"listeningTime": "Čas počúvania",
"activeSessions": "Aktívne relácie",
"nowPlaying": "Práve hrané",
"nowPlayingCount": "{count, plural, one {# stream} few {# streamy} many {# streamov} other {# streamov}}",
"error": {
"internalServerError": "Nepodarilo sa načítať údaje audio štatistík"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Zobraziť verziu agenta"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Nenašli sa žiadne systémy",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Systém",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Odstrániť",
"confirm": "",
"notification": {
"success": {
"title": "Kontajnery boli odstránené",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Pokaži različico agenta"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Ni najdenih sistemov",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Sistem",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Odstrani",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Visa agentversion"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Inga system hittades",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "System",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Ta bort",
"confirm": "",
"notification": {
"success": {
"title": "",
File diff suppressed because it is too large. Load diff
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Інверсія сортування"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": "Не вдалося отримати статистику контейнерів"
"internalServerError": "Не вдалося отримати статистику контейнерів",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Показувати тільки поточне відтворення",
"description": "Вимкнення цієї опції не працюватиме для Plex."
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "Зараз відтворюється",
"user": "Користувач",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "Статус",
"directPlay": "Пряме відтворення",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "Транскодування",
"paused": "Призупинено",
"remaining": "Залишилося {minutes} хв",
@@ -3768,18 +3794,6 @@
"label": "Показувати пісні",
"description": "Показати загальну кількість пісень у вашій музичній бібліотеці (Navidrome)"
},
"showNowPlaying": {
"label": "Показати, що зараз відтворюються",
"description": "Показувати пісні, що зараз відтворюються (Navidrome)"
},
"showNowPlayingList": {
"label": "Показати список відтворення зараз",
"description": "Відображати окремі треки замість лише кількості активних потоків (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Максимум елементів для відтворення",
"description": "Максимальна кількість треків для відображення у списку відтворення (Navidrome)"
},
"showLibraryCount": {
"label": "Показувати кількість бібліотек",
"description": "Показати кількість бібліотек на вашому екземплярі (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Подкасти",
"listeningTime": "Час прослуховування",
"activeSessions": "Активні Сесії",
"nowPlaying": "Зараз програється",
"nowPlayingCount": "{count, plural, one {# трансляція} few {# трансляції} many {# трансляцій} other {# трансляцій}}",
"error": {
"internalServerError": "Не вдалося отримати статистику аудіо"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Показати версію агента"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Системи не знайдені",
"error": {
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції."
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Система",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Видалити",
"confirm": "",
"notification": {
"success": {
"title": "Контейнери видалено",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "",
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "",
"user": "",
@@ -2734,6 +2757,9 @@
"id": "",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
@@ -3768,18 +3794,6 @@
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "",
"description": ""
@@ -3813,8 +3827,6 @@
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": ""
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Hiển thị phiên bản tác tử"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "Không tìm thấy hệ thống",
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "Hệ thống",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Xóa",
"confirm": "",
"notification": {
"success": {
"title": "",
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"error": {
"internalServerError": ""
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "只顯示目前播放中的項目",
"description": "停用此功能將無法在 Plex 上運作"
},
"showBitrate": {
"label": "",
"description": ""
},
"showLocation": {
"label": "",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
},
"items": {
"currentlyPlaying": "目前播放中",
"user": "使用者",
@@ -2734,6 +2757,9 @@
"id": "ID",
"status": "狀態",
"directPlay": "直接播放",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "轉碼中",
"paused": "已暫停",
"remaining": "剩餘 {minutes} 分鐘",
@@ -3768,18 +3794,6 @@
"label": "顯示歌曲",
"description": "顯示音樂庫中的歌曲總數 (Navidrome)"
},
"showNowPlaying": {
"label": "顯示正在播放",
"description": "顯示目前播放中的曲目 (Navidrome)"
},
"showNowPlayingList": {
"label": "顯示正在播放清單",
"description": "顯示個別曲目,而非僅顯示活動串流計數 (Navidrome)"
},
"maxNowPlayingItems": {
"label": "最大正在播放項目數",
"description": "在正在播放清單中顯示的最大曲目數量 (Navidrome)"
},
"showLibraryCount": {
"label": "顯示庫數量",
"description": "顯示你的執行個體上的庫數量 (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "播客",
"listeningTime": "聆聽時間",
"activeSessions": "活躍會話",
"nowPlaying": "正在播放",
"nowPlayingCount": "",
"error": {
"internalServerError": "無法擷取音訊統計資料"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "顯示代理版本"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"noRecords": "找不到系統",
"error": {
"internalServerError": "無法連線到 Beszel。請檢查整合設定。"
"internalServerError": "無法連線到 Beszel。請檢查整合設定。",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
},
"column": {
"system": "系統",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "移除",
"confirm": "",
"notification": {
"success": {
"title": "容器已移除",
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { localeConfigurations } from "./config";
import { supportedLanguages } from "./languages";
describe("supported translation languages", () => {
it("matches the locale configuration registry", () => {
expect(Object.keys(localeConfigurations)).toEqual([...supportedLanguages]);
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Keep the locale list dependency-free. This module is used by Next's proxy,
* where importing the full translation barrel would widen every route graph.
*/
export const supportedLanguages = [
"ca",
"cn",
"cr",
"cs",
"da",
"de",
"de-CH",
"en-gb",
"en",
"el",
"es",
"et",
"fi",
"fr",
"he",
"hr",
"hu",
"it",
"ja",
"ko",
"lt",
"lv",
"nl",
"no",
"pl",
"pt",
"pt-br",
"ro",
"ru",
"sk",
"sl",
"sv",
"tr",
"zh",
"uk",
"vi",
] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const fallbackLocale = "en" satisfies SupportedLanguage;
+1 -1
View File
@@ -1,7 +1,7 @@
import createMiddleware from "next-intl/middleware";
import type { SupportedLanguage } from ".";
import { supportedLanguages } from "./config";
import { supportedLanguages } from "./languages";
import { createRouting } from "./routing";
export const createI18nMiddleware = (defaultLocale: SupportedLanguage) =>
+3 -3
View File
@@ -1,9 +1,9 @@
import { defineRouting } from "next-intl/routing";
import { localeCookieKey } from "@homarr/definitions";
import { localeCookieKey } from "@homarr/definitions/cookie";
import type { SupportedLanguage } from "./config";
import { supportedLanguages } from "./config";
import type { SupportedLanguage } from "./languages";
import { supportedLanguages } from "./languages";
export const createRouting = (defaultLocale: SupportedLanguage) =>
defineRouting({
+1 -2
View File
@@ -3,8 +3,7 @@ import { z } from "zod/v4";
export const appHrefSchema = z
.string()
.trim()
.url()
.regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed)
.refine((val) => !/^javascript:/i.test(val), "JavaScript URIs are not permitted")
.or(z.literal(""))
.transform((value) => (value.length === 0 ? null : value))
.nullable();
+6 -1
View File
@@ -16,11 +16,16 @@
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./catalog": "./src/catalog.ts",
"./definition": "./src/definition.ts",
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
"./options": "./src/options.ts",
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
"./prefetch": "./src/prefetch.ts",
"./refetch-intervals": "./src/refetch-intervals.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
queryKey: [["widget", "anchorNotes"]],
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
+1
View File
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
queryKeys: [[["app", "byId"]], [["widget", "app"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "appWidgetPrefetch" });
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
});
for (const app of dbApps) {
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
}
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
+1
View File
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
queryKeys: [[["app", "byIds"]]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff. Show more