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:
commit
cc4e11fce4
235 files changed
+13729
-3807
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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("."));
|
||||
};
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
}));
|
||||
};
|
||||
@@ -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={{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "migrations/*/migrate.cjs"]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,9 +6,7 @@ export {
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
createGetSetChannel,
|
||||
setQueryCacheAsync,
|
||||
getQueryCacheAsync,
|
||||
removeQueryCacheAsync,
|
||||
createLockChannel,
|
||||
invalidateIntegrationCacheAsync,
|
||||
} from "./lib/channel";
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./component": "./src/component.ts",
|
||||
"./open": "./src/open.ts",
|
||||
"./styles.css": "./src/styles.css"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"use client";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
@@ -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,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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "容器已删除",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "הקונטיינרים הוסרו",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コンテナー削除",
|
||||
|
||||
@@ -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": "컨테이너 제거됨",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "Контейнеры удалены",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
@@ -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": "Контейнери видалено",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user