chore(release): automatic release v1.0.0

This commit is contained in:
homarr-releases[bot]
2024-12-31 10:41:19 +00:00
committed by GitHub
95 changed files with 6144 additions and 1781 deletions

View File

@@ -4,6 +4,14 @@
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# The below secret is not used anywhere but required for Auth.js (Would encrypt JWTs and Mail hashes, both not used)
AUTH_SECRET="supersecret"
# The below secret is used to encrypt integration secrets in the database.
# It should be a 32-byte string, generated by running `openssl rand -hex 32` on Unix
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
@@ -20,11 +28,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# You can generate the secret via 'openssl rand -base64 32' on Unix
# @see https://next-auth.js.org/configuration/options#secret
AUTH_SECRET='supersecret'
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger

View File

@@ -13,11 +13,6 @@ on:
required: false
default: true
description: Send notifications
push-image:
type: boolean
required: false
default: true
description: Push Docker Image
permissions:
contents: write
@@ -112,7 +107,6 @@ jobs:
NEXT_VERSION: ${{ needs.release.outputs.version }}
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || github.events.inputs.push-image == true }}
steps:
- uses: actions/checkout@v4
with:
@@ -143,13 +137,13 @@ jobs:
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
type=raw,value=${{ env.NEXT_VERSION }}
- name: Build and maybe push
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
context: .
push: ${{ env.PUSH_IMAGE }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
@@ -160,4 +154,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. ${{ env.PUSH_IMAGE == 'true' && '' || 'This was a dry run' }}"
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."

View File

@@ -39,5 +39,5 @@
"i18n-ally.localesPaths": [
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "auto",
"i18n-ally.keystyle": "nested",
}

View File

@@ -25,51 +25,41 @@ RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
# gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext su-exec
# gettext is required for envsubst, openssl for generating AUTH_SECRET, su-exec for running application as non-root
RUN apk add --no-cache redis nginx bash gettext su-exec openssl
RUN mkdir /appdata
VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
COPY --from=builder /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
# Don't run production as root
RUN chown -R nextjs:nodejs /secrets
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
RUN mkdir -p /var/cache/nginx && \
mkdir -p /var/log/nginx && \
mkdir -p /var/lib/nginx && \
touch /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=builder /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY scripts/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
COPY --chown=nextjs:nodejs scripts/generateRandomSecureKey.js ./generateRandomSecureKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
COPY --from=builder /app/apps/nextjs/.next/standalone ./
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public
COPY scripts/run.sh ./run.sh
COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh
COPY packages/redis/redis.conf /app/redis.conf
COPY nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite'
@@ -78,4 +68,4 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
CMD ["sh", "run.sh"]

View File

@@ -1,6 +1,7 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import "@homarr/common/env.mjs";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";

View File

@@ -87,7 +87,7 @@
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.1",
"concurrently": "^9.1.2",
"eslint": "^9.17.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",

View File

@@ -3,13 +3,13 @@
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -38,6 +38,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
kind,
value: "",
})),
attemptSearchEngineCreation: true,
},
});
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
@@ -78,6 +79,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
);
};
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack>
@@ -104,6 +107,16 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
</Stack>
</Fieldset>
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/>
)}
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}

View File

@@ -5,7 +5,22 @@ export const createHomarrContainer = () => {
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
}
return new GenericContainer("homarr-e2e")
.withExposedPorts(7575)
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
return withLogs(
new GenericContainer("homarr-e2e")
.withExposedPorts(7575)
.withEnvironment({
SECRET_ENCRYPTION_KEY: "0".repeat(64),
})
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575)),
);
};
export const withLogs = (container: GenericContainer) => {
container.withLogConsumer((stream) =>
stream
.on("data", (line) => console.log(line))
.on("err", (line) => console.error(line))
.on("end", () => console.log("Stream closed")),
);
return container;
};

View File

@@ -52,7 +52,7 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@9.15.1",
"packageManager": "pnpm@9.15.2",
"engines": {
"node": ">=22.12.0"
},

View File

@@ -41,10 +41,11 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"lodash.clonedeep": "^4.5.0",
"next": "^14.2.22",
"react": "^19.0.0",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.1.0"
"trpc-to-openapi": "^2.1.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -11,9 +11,11 @@ import {
integrations,
integrationSecrets,
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationDefs,
@@ -192,6 +194,18 @@ export const integrationRouter = createTRPCRouter({
})),
);
}
if (input.attemptSearchEngineCreation) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
}),
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -411,6 +425,36 @@ interface AddSecretInput {
value: string;
kind: IntegrationSecretKind;
}
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,

View File

@@ -2,8 +2,10 @@ import { TRPCError } from "@trpc/server";
import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema";
import { integrationCreator } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
@@ -56,9 +58,32 @@ export const searchEngineRouter = createTRPCRouter({
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
limit: input.limit,
});
}),
getMediaRequestOptions: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(validation.common.mediaRequestOptions)
.query(async ({ ctx, input }) => {
const integration = integrationCreator(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(validation.common.requestMedia)
.mutation(async ({ ctx, input }) => {
const integration = integrationCreator(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure
.requiresPermission("search-engine-create")
.input(validation.searchEngine.manage)

View File

@@ -179,6 +179,7 @@ describe("create should create a new integration", () => {
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
@@ -201,6 +202,48 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("with create integration access should create a new integration when creating search engine", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
url: "http://jellyseerr.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: true,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbSearchEngine = await db.query.searchEngines.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
);
});
test("without create integration access should throw permission error", async () => {
// Arrange
const db = createDb();
@@ -213,6 +256,7 @@ describe("create should create a new integration", () => {
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
};
// Act

View File

@@ -8,6 +8,7 @@ import { indexerManagerRouter } from "./indexer-manager";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding";
import { minecraftRouter } from "./minecraft";
import { notebookRouter } from "./notebook";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
@@ -27,4 +28,5 @@ export const widgetRouter = createTRPCRouter({
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter,
minecraft: minecraftRouter,
});

View File

@@ -21,6 +21,7 @@ export const mediaServerRouter = createTRPCRouter({
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationKind: integration.kind,
sessions: data,
};
}),

View File

@@ -0,0 +1,36 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const serverStatusInputSchema = z.object({
domain: z.string().nonempty(),
isBedrockServer: z.boolean(),
});
export const minecraftRouter = createTRPCRouter({
getServerStatus: publicProcedure.input(serverStatusInputSchema).query(async ({ input }) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
return observable<MinecraftServerStatus>((emit) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});
return () => {
unsubscribe();
};
});
}),
});

View File

@@ -64,7 +64,6 @@ export const env = createEnv({
server: {
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
AUTH_PROVIDERS: authProvidersSchema,
...(authProviders.includes("oidc")
? {
@@ -98,7 +97,6 @@ export const env = createEnv({
runtimeEnv: {
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,

28
packages/common/env.mjs Normal file
View File

@@ -0,0 +1,28 @@
import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({
server: {
SECRET_ENCRYPTION_KEY: z
.string({
required_error: `SECRET_ENCRYPTION_KEY is required${errorSuffix}`,
})
.min(64, {
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
})
.max(64, {
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
})
.regex(/^[0-9a-fA-F]{64}$/, {
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
}),
},
runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
});

View File

@@ -8,7 +8,8 @@
".": "./index.ts",
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts"
"./client": "./src/client.ts",
"./env.mjs": "./env.mjs"
},
"typesVersions": {
"*": {

View File

@@ -1,20 +1,12 @@
import crypto from "crypto";
import { logger } from "@homarr/log";
import { env } from "../env.mjs";
const algorithm = "aes-256-cbc"; //Using AES encryption
const fallbackKey = "0000000000000000000000000000000000000000000000000000000000000000";
const encryptionKey = process.env.ENCRYPTION_KEY ?? fallbackKey; // Fallback to a default key for local development
if (encryptionKey === fallbackKey) {
logger.warn("Using a fallback encryption key, stored secrets are not secure");
// We never want to use the fallback key in production
if (process.env.NODE_ENV === "production" && process.env.CI !== "true") {
throw new Error("Encryption key is not set");
}
}
const key = Buffer.from(encryptionKey, "hex");
// We fallback to a key of 0s if the key was not provided because env validation was skipped
// This should only be the case in CI
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);

View File

@@ -9,6 +9,7 @@ import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -33,6 +34,7 @@ export const jobGroup = createCronJobGroup({
sessionCleanup: sessionCleanupJob,
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
minecraftServerStatus: minecraftServerStatusJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];

View File

@@ -0,0 +1,25 @@
import SuperJSON from "superjson";
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import type { WidgetComponentProps } from "../../../widgets/src";
import { createCronJob } from "../lib";
export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback(
async () => {
const dbItems = await db.query.items.findMany({
where: eq(items.kind, "minecraftServerStatus"),
});
await Promise.allSettled(
dbItems.map(async (item) => {
const options = SuperJSON.parse<WidgetComponentProps<"minecraftServerStatus">["options"]>(item.options);
const innerHandler = minecraftServerStatusRequestHandler.handler(options);
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
);
},
);

View File

@@ -0,0 +1 @@
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_short_unique` UNIQUE(`short`);

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,13 @@
"when": 1733777544067,
"tag": "0017_tired_penance",
"breakpoints": true
},
{
"idx": 18,
"version": "5",
"when": 1735593853768,
"tag": "0018_mighty_shaman",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `search_engine_short_unique` ON `search_engine` (`short`);

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,13 @@
"when": 1733777395703,
"tag": "0017_small_rumiko_fujikawa",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1735593831501,
"tag": "0018_cheerful_tattoo",
"breakpoints": true
}
]
}

View File

@@ -389,7 +389,7 @@ export const searchEngines = mysqlTable("search_engine", {
id: varchar({ length: 64 }).notNull().primaryKey(),
iconUrl: text().notNull(),
name: varchar({ length: 64 }).notNull(),
short: varchar({ length: 8 }).notNull(),
short: varchar({ length: 8 }).unique().notNull(),
description: text(),
urlTemplate: text(),
type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),

View File

@@ -375,7 +375,7 @@ export const searchEngines = sqliteTable("search_engine", {
id: text().notNull().primaryKey(),
iconUrl: text().notNull(),
name: text().notNull(),
short: text().notNull(),
short: text().unique().notNull(),
description: text(),
urlTemplate: text(),
type: text().$type<SearchEngineType>().notNull().default("generic"),

View File

@@ -90,6 +90,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/lists"
| "/docs/tags/management"
| "/docs/tags/media"
| "/docs/tags/minecraft"
| "/docs/tags/monitoring"
| "/docs/tags/news"
| "/docs/tags/notebook"
@@ -190,6 +191,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/indexer-manager"
| "/docs/widgets/media-requests"
| "/docs/widgets/media-server"
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/notebook"
| "/docs/widgets/rss"
| "/docs/widgets/video"

View File

@@ -15,6 +15,7 @@ export const widgetKinds = [
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"minecraftServerStatus",
"rssFeed",
"bookmarks",
"indexerManager",

View File

@@ -1,2 +1,3 @@
export * from "./src/icons-fetcher";
export * from "./src/types";
export * from "./src/auto-icon-searcher";

View File

@@ -0,0 +1,9 @@
import type { Database } from "@homarr/db";
import { like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
export const getIconForNameAsync = async (db: Database, name: string) => {
return await db.query.icons.findFirst({
where: like(icons.name, `%${name}%`),
});
};

View File

@@ -1,3 +1,3 @@
export interface ISearchableIntegration {
searchAsync(query: string): Promise<{ image?: string; name: string; link: string }[]>;
export interface ISearchableIntegration<TResult extends { image?: string; name: string; link: string }> {
searchAsync(query: string): Promise<TResult[]>;
}

View File

@@ -6,11 +6,20 @@ import type { ISearchableIntegration } from "../base/searchable-integration";
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
interface OverseerrSearchResult {
id: number;
name: string;
link: string;
image?: string;
text?: string;
type: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number]["mediaType"];
}
/**
* Overseerr Integration. See https://api-docs.overseerr.dev
*/
export class OverseerrIntegration extends Integration implements ISearchableIntegration {
public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> {
export class OverseerrIntegration extends Integration implements ISearchableIntegration<OverseerrSearchResult> {
public async searchAsync(query: string) {
const response = await fetch(this.url("/api/v1/search", { query }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -23,13 +32,53 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
return schemaData.results.map((result) => ({
id: result.id,
name: "name" in result ? result.name : result.title,
link: this.url(`/${result.mediaType}/${result.id}`).toString(),
image: constructSearchResultImage(result),
text: "overview" in result ? result.overview : undefined,
type: result.mediaType,
inLibrary: result.mediaInfo !== undefined,
}));
}
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await fetch(url, {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
return await mediaInformationSchema.parseAsync(await response.json());
}
/**
* Request a media. See https://api-docs.overseerr.dev/#/request/post_request
* @param mediaType The media type to request. Can be "movie" or "tv".
* @param id The Overseerr ID of the media to request.
* @param seasons A list of the seasons that should be requested.
*/
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
const url = this.url("/api/v1/request");
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({
mediaType,
mediaId: id,
seasons,
}),
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
"Content-Type": "application/json",
},
});
if (response.status !== 201) {
throw new Error(
`Status code ${response.status} does not match the expected status code. The request was likely not created. Response: ${await response.text()}`,
);
}
}
public async testConnectionAsync(): Promise<void> {
const response = await fetch(this.url("/api/v1/auth/me"), {
headers: {
@@ -220,6 +269,27 @@ interface MovieInformation {
releaseDate: string;
}
const mediaInformationSchema = z.union([
z.object({
id: z.number(),
overview: z.string(),
seasons: z.array(
z.object({
id: z.number(),
name: z.string().min(0),
episodeCount: z.number().min(0),
}),
),
numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"),
}),
z.object({
id: z.number(),
overview: z.string(),
posterPath: z.string().startsWith("/"),
}),
]);
const searchSchema = z.object({
results: z
.array(
@@ -230,6 +300,7 @@ const searchSchema = z.object({
name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
mediaInfo: z.object({}).optional(),
}),
z.object({
id: z.number(),
@@ -237,12 +308,14 @@ const searchSchema = z.object({
title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
mediaInfo: z.object({}).optional(),
}),
z.object({
id: z.number(),
mediaType: z.literal("person"),
name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: z.object({}).optional(),
}),
]),
)

View File

@@ -1,3 +1,4 @@
export * from "./boards";
export * from "./invites";
export * from "./groups";
export * from "./search-engines";

View File

@@ -0,0 +1 @@
export { RequestMediaModal } from "./request-media-modal";

View File

@@ -0,0 +1,119 @@
import { useMemo } from "react";
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
interface RequestMediaModalProps {
integrationId: string;
mediaId: number;
mediaType: "movie" | "tv";
}
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType,
});
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
onSuccess() {
actions.closeModal();
showSuccessNotification({
message: t("common.notification.create.success"),
});
},
});
const isPending = isPendingQuery || isPendingMutation;
const t = useI18n();
const columns = useMemo<MRT_ColumnDef<Season>[]>(
() => [
{
accessorKey: "name",
header: t("search.engine.media.request.modal.table.header.season"),
},
{
accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"),
},
],
[],
);
const table = useTranslatedMantineReactTable({
columns,
data: data && "seasons" in data ? data.seasons : [],
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableSorting: false,
enableSelectAll: true,
enableRowSelection: true,
mantineTableProps: {
highlightOnHover: false,
striped: "odd",
withColumnBorders: true,
withRowBorders: true,
withTableBorder: true,
},
initialState: {
density: "xs",
},
});
const anySelected = Object.keys(table.getState().rowSelection).length > 0;
const handleMutate = () => {
const selectedSeasons = table.getSelectedRowModel().rows.flatMap((row) => row.original.id);
mutate({
integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType,
seasons: selectedSeasons,
});
};
return (
<Stack>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
{data && (
<Group wrap="nowrap" align="start">
<Image
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt="poster"
w={100}
radius="md"
/>
<Text c="dimmed" style={{ flex: "1" }}>
{data.overview}
</Text>
</Group>
)}
{innerProps.mediaType === "tv" && <MRT_Table table={table} />}
<Group justify="end">
<Button onClick={actions.closeModal} variant="light">
{t("common.action.cancel")}
</Button>
<Button onClick={handleMutate} disabled={!anySelected && innerProps.mediaType === "tv"}>
{t("search.engine.media.request.modal.button.send")}
</Button>
</Group>
</Stack>
);
}).withOptions({
size: "xl",
});
interface Season {
id: number;
name: string;
episodeCount: number;
}

View File

@@ -5,6 +5,7 @@ export {
createItemAndIntegrationChannel,
createItemChannel,
createIntegrationOptionsChannel,
createWidgetOptionsChannel,
createChannelWithLatestAndEvents,
handshakeAsync,
createSubPubChannel,

View File

@@ -183,6 +183,16 @@ export const createIntegrationOptionsChannel = <TData>(
return createChannelWithLatestAndEvents<TData>(channelName);
};
export const createWidgetOptionsChannel = <TData>(
widgetKind: WidgetKind,
queryKey: string,
options: Record<string, unknown>,
) => {
const optionsKey = hashObjectBase64(options);
const channelName = `widget:${widgetKind}:${queryKey}:options:${optionsKey}`;
return createChannelWithLatestAndEvents<TData>(channelName);
};
export const createItemChannel = <TData>(itemId: string) => {
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
};

View File

@@ -29,7 +29,7 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"octokit": "^4.0.2",
"octokit": "^4.0.3",
"pretty-print-error": "^1.1.2",
"superjson": "2.2.2"
},

View File

@@ -0,0 +1,36 @@
import type { Duration } from "dayjs/plugin/duration";
import type { WidgetKind } from "@homarr/definitions";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedRequestHandler } from "./cached-request-handler";
interface Options<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (input: TInput) => Promise<TData>;
cacheDuration: Duration;
widgetKind: TKind;
}
export const createCachedWidgetRequestHandler = <
TData,
TKind extends WidgetKind,
TInput extends Record<string, unknown>,
>(
requestHandlerOptions: Options<TData, TKind, TInput>,
) => {
return {
handler: (widgetOptions: TInput) =>
createCachedRequestHandler({
queryKey: requestHandlerOptions.queryKey,
requestAsync: async (input: TInput) => {
return await requestHandlerOptions.requestAsync(input);
},
cacheDuration: requestHandlerOptions.cacheDuration,
createRedisChannel(input, options) {
return createWidgetOptionsChannel<TData>(requestHandlerOptions.widgetKind, options.queryKey, input);
},
}).handler(widgetOptions),
};
};

View File

@@ -0,0 +1,35 @@
import dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTimeout } from "@homarr/common";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHandler({
queryKey: "minecraftServerStatusApiResult",
widgetKind: "minecraftServerStatus",
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
const path = `/3/${input.isBedrockServer ? "bedrock/" : ""}${input.domain}`;
const response = await fetchWithTimeout(`https://api.mcsrvstat.us${path}`);
return responseSchema.parse(await response.json());
},
cacheDuration: dayjs.duration(5, "minutes"),
});
const responseSchema = z
.object({
online: z.literal(false),
})
.or(
z.object({
online: z.literal(true),
players: z.object({
online: z.number(),
max: z.number(),
}),
icon: z.string().optional(),
}),
);
export type MinecraftServerStatus = z.infer<typeof responseSchema>;

View File

@@ -4,14 +4,25 @@ import { ChildrenActionItem } from "./items/children-action-item";
interface SpotlightChildrenActionsProps {
childrenOptions: inferSearchInteractionOptions<"children">;
query: string;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => {
export const SpotlightChildrenActions = ({
childrenOptions,
query,
setChildrenOptions,
}: SpotlightChildrenActionsProps) => {
const actions = childrenOptions.useActions(childrenOptions.option, query);
return actions
.filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide))
.map((action) => (
<ChildrenActionItem key={action.key} childrenOptions={childrenOptions} query={query} action={action} />
<ChildrenActionItem
key={action.key}
childrenOptions={childrenOptions}
query={query}
action={action}
setChildrenOptions={setChildrenOptions}
/>
));
};

View File

@@ -8,9 +8,10 @@ interface ChildrenActionItemProps {
childrenOptions: inferSearchInteractionOptions<"children">;
query: string;
action: ReturnType<inferSearchInteractionOptions<"children">["useActions"]>[number];
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => {
export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => {
const interaction = action.useInteraction(childrenOptions.option, query);
const renderRoot =
@@ -20,10 +21,20 @@ export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenA
}
: undefined;
const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined;
const onClick =
interaction.type === "javaScript"
? interaction.onSelect
: interaction.type === "children"
? () => setChildrenOptions(interaction)
: undefined;
return (
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
<Spotlight.Action
renderRoot={renderRoot}
onClick={onClick}
closeSpotlightOnTrigger={interaction.type !== "children"}
className={classes.spotlightAction}
>
<action.Component {...childrenOptions.option} />
</Spotlight.Action>
);

View File

@@ -140,7 +140,11 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
<MantineSpotlight.ActionsList>
{childrenOptions ? (
<SpotlightChildrenActions childrenOptions={childrenOptions} query={query} />
<SpotlightChildrenActions
childrenOptions={childrenOptions}
query={query}
setChildrenOptions={setChildrenOptions}
/>
) : (
<SpotlightActionGroups
setMode={(mode) => {

View File

@@ -10,7 +10,10 @@ export interface CreateChildrenOptionsProps<TParentOptions extends Record<string
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
key: string;
Component: (option: TParentOptions) => JSX.Element;
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
useInteraction: (
option: TParentOptions,
query: string,
) => inferSearchInteractionDefinition<"link" | "javaScript" | "children">;
hide?: boolean | ((option: TParentOptions) => boolean);
}

View File

@@ -1,8 +1,12 @@
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { IconDownload, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { RequestMediaModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
@@ -11,6 +15,100 @@ import { interaction } from "../../lib/interaction";
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type MediaRequestChildrenProps = {
result: {
id: number;
image?: string;
name: string;
link: string;
text?: string;
type: "tv" | "movie";
inLibrary: boolean;
};
integration: {
kind: IntegrationKind;
url: string;
id: string;
};
};
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() {
const { openModal } = useModalAction(RequestMediaModal);
return [
{
key: "request",
hide: (option) => option.result.inLibrary,
Component(option) {
const t = useScopedI18n("search.mode.media");
return (
<Group mx="md" my="sm" wrap="nowrap">
<IconDownload stroke={1.5} />
{option.result.type === "tv" && <Text>{t("requestSeries")}</Text>}
{option.result.type === "movie" && <Text>{t("requestMovie")}</Text>}
</Group>
);
},
useInteraction: interaction.javaScript((option) => ({
onSelect() {
openModal(
{
integrationId: option.integration.id,
mediaId: option.result.id,
mediaType: option.result.type,
},
{
title(t) {
return t("search.engine.media.request.modal.title", { name: option.result.name });
},
},
);
},
})),
},
{
key: "open",
Component({ integration }) {
const tChildren = useScopedI18n("search.mode.media");
return (
<Group mx="md" my="sm" wrap="nowrap">
<IconSearch stroke={1.5} />
<Text>{tChildren("openIn", { kind: getIntegrationName(integration.kind) })}</Text>
</Group>
);
},
useInteraction({ result }) {
return {
type: "link",
href: result.link,
newTab: true,
};
},
},
];
},
DetailComponent({ options }) {
return (
<Group mx="md" my="sm" wrap="nowrap">
{options.result.image ? (
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} />
) : (
<IconSearch stroke={1.5} size={35} />
)}
<Stack gap={2}>
<Text>{options.result.name}</Text>
{options.result.text && (
<Text c="dimmed" size="sm" lineClamp={2}>
{options.result.text}
</Text>
)}
</Stack>
</Group>
);
},
});
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
useActions: (searchEngine, query) => {
const { data } = clientApi.integration.searchInIntegration.useQuery(
@@ -64,10 +162,48 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
</Group>
);
},
useInteraction: interaction.link(() => ({
href: searchResult.link,
newTab: true,
})),
useInteraction(searchEngine) {
if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type");
}
if (!searchEngine.integration) {
throw new Error("Invalid search engine integration");
}
if (
getIntegrationKindsByCategory("mediaRequest").some(
(categoryKind) => categoryKind === searchEngine.integration?.kind,
) &&
"type" in searchResult
) {
const type = searchResult.type;
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: true,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration: searchEngine.integration,
}),
};
}
return {
type: "link",
href: searchResult.link,
newTab: true,
};
},
}));
},
DetailComponent({ options }) {

File diff suppressed because it is too large Load Diff

View File

@@ -1381,7 +1381,7 @@
"memory": "crwdns5734:0{memory}crwdne5734:0",
"memoryAvailable": "crwdns5736:0{memoryAvailable}crwdnd5736:0{percent}crwdne5736:0",
"version": "crwdns5738:0{version}crwdne5738:0",
"uptime": "crwdns5740:0{days}crwdnd5740:0{hours}crwdnd5740:0{minutes}crwdne5740:0",
"uptime": "crwdns7008:0{months}crwdnd7008:0{days}crwdnd7008:0{hours}crwdnd7008:0{minutes}crwdne7008:0",
"loadAverage": "crwdns5742:0crwdne5742:0",
"minute": "crwdns5744:0crwdne5744:0",
"minutes": "crwdns5746:0{count}crwdne5746:0",
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "crwdns5810:0crwdne5810:0",
"description": "crwdns5812:0crwdne5812:0",
"option": {}
"option": {},
"items": {
"user": "crwdns7010:0crwdne7010:0",
"name": "crwdns7012:0crwdne7012:0",
"id": "crwdns7014:0crwdne7014:0"
}
},
"downloads": {
"name": "crwdns5814:0crwdne5814:0",

View File

@@ -68,20 +68,20 @@
"subtitle": "",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Uživatel byl vytvořen",
"message": "Uživatel byl úspěšně vytvořen"
},
"error": {
"title": ""
"title": "Vytvoření uživatele se nezdařilo"
}
}
},
"group": {
"title": "",
"title": "Externí skupina",
"subtitle": "",
"form": {
"name": {
"label": "",
"label": "Název skupiny",
"description": ""
}
}
@@ -91,35 +91,35 @@
"subtitle": ""
},
"finish": {
"title": "",
"subtitle": "",
"description": "",
"title": "Dokončit nastavení",
"subtitle": "Jste připraveni začít!",
"description": "Úspěšně jste dokončili proces nastavení. Nyní můžete začít používat Homarr. Vyberte další akci:",
"action": {
"goToBoard": "",
"createBoard": "",
"inviteUser": "",
"docs": ""
"goToBoard": "Přejít na plochu {name}",
"createBoard": "Vytvořte svou první plochu",
"inviteUser": "Pozvat ostatní uživatele",
"docs": "Přečtěte si dokumentaci"
}
}
},
"backToStart": ""
"backToStart": "Zpět na začátek"
},
"user": {
"title": "Uživatelé",
"name": "Uživatel",
"page": {
"login": {
"title": "",
"subtitle": ""
"title": "Přihlaste se ke svému účtu",
"subtitle": "Vítejte zpět! Zadejte prosím Vaše přihlašovací údaje"
},
"invite": {
"title": "",
"subtitle": "",
"description": ""
"subtitle": "Vítejte v Homarru! Vytvořte si prosím svůj účet",
"description": "Byli jste pozváni {username}"
},
"init": {
"title": "",
"subtitle": ""
"title": "Nová instalace Homarru",
"subtitle": "Vytvořte prosím původního administrátora"
}
},
"field": {
@@ -133,45 +133,45 @@
"password": {
"label": "Heslo",
"requirement": {
"length": "",
"length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno",
"uppercase": "Obsahuje velké písmeno",
"number": "Obsahuje číslo",
"special": ""
"special": "Obsahuje speciální symbol"
}
},
"passwordConfirm": {
"label": "Potvrďte heslo"
},
"previousPassword": {
"label": ""
"label": "Původní heslo"
},
"homeBoard": {
"label": ""
"label": "Domovská plocha"
},
"pingIconsEnabled": {
"label": ""
"label": "Používat ikony pro pingy"
}
},
"error": {
"usernameTaken": ""
"usernameTaken": "Uživatelské jméno je již používáno"
},
"action": {
"login": {
"label": "Přihlásit se",
"labelWith": "",
"labelWith": "Přihlaste se pomocí {provider}",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Přihlášení bylo úspěšné",
"message": "Nyní jste přihlášeni"
},
"error": {
"title": "",
"message": ""
"title": "Příhlášení selhalo",
"message": "Vaše přihlášení se nezdařilo"
}
},
"forgotPassword": {
"label": "",
"label": "Zapomněli jste Vaše heslo?",
"description": ""
}
},
@@ -180,40 +180,40 @@
"notification": {
"success": {
"title": "Účet byl vytvořen",
"message": ""
"message": "Chcete-li pokračovat, přihlaste se"
},
"error": {
"title": "",
"message": ""
"title": "Vytvoření účtu se nezdařilo",
"message": "Váš účet se nepodařilo vytvořit"
}
}
},
"create": "Vytvořit uživatele",
"changePassword": {
"label": "",
"label": "Změnit heslo",
"notification": {
"success": {
"message": ""
"message": "Heslo bylo úspěšně změněno"
},
"error": {
"message": ""
"message": "Nepodařilo se změnit heso"
}
}
},
"changeHomeBoard": {
"notification": {
"success": {
"message": ""
"message": "Domovská plocha byla úspěšně změněna"
},
"error": {
"message": ""
"message": "Nepodařilo se změnit domovskou plochu"
}
}
},
"changeFirstDayOfWeek": {
"notification": {
"success": {
"message": ""
"message": "První den v týdnu byl úspěšně změněn"
},
"error": {
"message": ""
@@ -247,14 +247,14 @@
}
},
"removeImage": {
"label": "",
"confirm": "",
"label": "Odebrat obrázek",
"confirm": "Opravdu chcete odstranit tento obrázek?",
"notification": {
"success": {
"message": ""
"message": "Obrázek byl úspěšně odstraněn"
},
"error": {
"message": ""
"message": "Obrázek nelze odstranit"
}
}
}
@@ -262,42 +262,42 @@
"editProfile": {
"notification": {
"success": {
"message": ""
"message": "Profil byl úspěšně aktualizován"
},
"error": {
"message": ""
"message": "Profil nelze aktualizovat"
}
}
},
"delete": {
"label": "",
"description": "",
"confirm": ""
"label": "Trvale smazat uživatele",
"description": "Smaže tohoto uživatele včetně jeho nastavení. Neodstraní žádné plochy. Uživatel nebude upozorněn.",
"confirm": "Jste si jisti, že chcete odstranit uživatele {username} včetně jejich nastavení?"
},
"select": {
"label": "",
"notFound": ""
"label": "Vybrat uživatele",
"notFound": "Nebyl nalezen žádný uživatel"
},
"transfer": {
"label": ""
"label": "Vyberte nového vlastníka"
}
}
},
"group": {
"title": "",
"name": "",
"search": "",
"title": "Skupiny",
"name": "Skupina",
"search": "Najít skupinu",
"field": {
"name": "Název",
"members": ""
"members": "Členové"
},
"permission": {
"admin": {
"title": "Administrátor",
"item": {
"admin": {
"label": "",
"description": ""
"label": "Administrátor",
"description": "Uživatelé s tímto oprávněním mají plný přístup ke všem funkcím a nastavením"
}
}
},
@@ -305,20 +305,20 @@
"title": "Aplikace",
"item": {
"create": {
"label": "",
"description": ""
"label": "Vytvořit aplikace",
"description": "Povolit členům vytvářet aplikace"
},
"use-all": {
"label": "",
"description": ""
"label": "Používat všechny aplikace",
"description": "Umožňuje členům přidávat do jejich ploch jakékoli aplikace"
},
"modify-all": {
"label": "",
"description": ""
"label": "Upravit všechny aplikace",
"description": "Umožňuje členům upravovat všechny aplikace"
},
"full-all": {
"label": "",
"description": ""
"label": "Úplný přístup k aplikacím",
"description": "Umožňuje členům spravovat, používat a mazat jakoukoli aplikaci"
}
}
},
@@ -326,16 +326,16 @@
"title": "Plochy",
"item": {
"create": {
"label": "",
"description": ""
"label": "Tvorba ploch",
"description": "Umožňuje členům vytvářet plochy"
},
"view-all": {
"label": "",
"description": ""
"label": "Zobrazit všechny plochy",
"description": "Umožňuje členům zobrazit všechny plochy"
},
"modify-all": {
"label": "",
"description": ""
"label": "Upravit všechny plochy",
"description": "Umožňuje členům upravovat všechny plochy (nezahrnuje kontrolu přístupu a nebezpečnou zónu)"
},
"full-all": {
"label": "",
@@ -344,83 +344,83 @@
}
},
"integration": {
"title": "",
"title": "Integrace",
"item": {
"create": {
"label": "",
"description": ""
"label": "Vytvořit integrace",
"description": "Povolit členům vytvářet integrace"
},
"use-all": {
"label": "",
"description": ""
"label": "Použít všechny integrace",
"description": "Umožňuje členům přidávat do jejich ploch jakékoli integrace"
},
"interact-all": {
"label": "",
"description": ""
"label": "Interakce s jakoukoli integrací",
"description": "Umožnit členům interagovat s jakoukoli integrací"
},
"full-all": {
"label": "",
"description": ""
"label": "Plný přístup k integraci",
"description": "Umožňuje členům spravovat, používat a interagovat s jakoukoli integrací"
}
}
},
"media": {
"title": "",
"title": "Média",
"item": {
"upload": {
"label": "",
"description": ""
"label": "Nahrávat média",
"description": "Povolit členům nahrávat média"
},
"view-all": {
"label": "",
"description": ""
"label": "Zobrazit všechna média",
"description": "Umožňuje členům nahrávat média"
},
"full-all": {
"label": "",
"label": "Úplný přístup k médiím",
"description": ""
}
}
},
"other": {
"title": "",
"title": "Ostatní",
"item": {
"view-logs": {
"label": "",
"description": ""
"label": "Zobrazit logy",
"description": "Umožňuje členům prohlížet logy"
}
}
},
"search-engine": {
"title": "",
"title": "Vyhledávače",
"item": {
"create": {
"label": "",
"description": ""
"label": "Vytvořit vyhledávače",
"description": "Umožňuje členům vytvářet vyhledávače"
},
"modify-all": {
"label": "",
"description": ""
"description": "Umožňuje členům upravovat všechny vyhledávače"
},
"full-all": {
"label": "",
"description": ""
"label": "Úplný přístup k vyhledávačům",
"description": "Umožňuje členům spravovat a mazat jakýkoliv vyhledávač"
}
}
}
},
"memberNotice": {
"mixed": "",
"external": ""
"mixed": "Někteří členové jsou od externích poskytovatelů a nelze je spravovat zde",
"external": "Všichni členové jsou od externích poskytovatelů a nelze je spravovat zde"
},
"reservedNotice": {
"message": ""
"message": "Tato skupina je vyhrazena pro používání systému a omezuje některé akce. <checkoutDocs></checkoutDocs>"
},
"action": {
"create": {
"label": "",
"label": "Nová skupina",
"notification": {
"success": {
"message": ""
"message": "Skupina byla úspěšně vytvořena"
},
"error": {
"message": ""
@@ -506,34 +506,34 @@
},
"error": {
"title": "",
"message": ""
"message": "Aplikaci nelze vytvořit"
}
}
},
"edit": {
"title": "",
"title": "Upravit aplikaci",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Změny úspěšně aplikovány",
"message": "Aplikace byla úspěšně uložena"
},
"error": {
"title": "",
"message": ""
"title": "Nepodařilo se uložit změny",
"message": "Aplikaci se nepodařilo uložit"
}
}
},
"delete": {
"title": "",
"message": "",
"title": "Vymazat aplikaci",
"message": "Jste si jisti, že chcete vymazat aplikaci {name}?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Úspěšně odstraněno",
"message": "Aplikace byla úspěšně odstraněna"
},
"error": {
"title": "",
"message": ""
"title": "Smazání se nezdařilo",
"message": "Nelze odstranit aplikaci"
}
}
}
@@ -543,15 +543,15 @@
"label": "Název"
},
"description": {
"label": ""
"label": "Popis"
},
"url": {
"label": ""
"label": "Url"
}
},
"action": {
"select": {
"label": "",
"label": "Vybrat aplikaci",
"notFound": ""
}
}
@@ -559,49 +559,49 @@
"integration": {
"page": {
"list": {
"title": "",
"search": "",
"title": "Integrace",
"search": "Prohledat integrace",
"noResults": {
"title": ""
"title": "Zatím nejsou žádné integrace"
}
},
"create": {
"title": "",
"title": "Nová integrace {name}",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Vytvoření bylo úspěšné",
"message": "Integrace byla úspěšně vytvořena"
},
"error": {
"title": "",
"message": ""
"title": "Vytvoření se nezdařilo",
"message": "Integraci se nepodařilo vytvořit"
}
}
},
"edit": {
"title": "",
"title": "Upravit integraci {name}",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Změny byly úspěšně aplikovány",
"message": "Integrace byla úspěšně uložena"
},
"error": {
"title": "",
"message": ""
"title": "Nepodařilo se uložit změny",
"message": "Integraci se nepodařilo uložit"
}
}
},
"delete": {
"title": "",
"message": "",
"title": "Smazat integraci",
"message": "Jste si jisti, že chcete vymazat integraci {name}?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Odstránění bylo úspěšné",
"message": "Integrace byla úspěšně smazána"
},
"error": {
"title": "",
"message": ""
"title": "Smazání se nezdařilo",
"message": "Nepodařilo se odstranit integraci"
}
}
}
@@ -611,41 +611,41 @@
"label": "Název"
},
"url": {
"label": ""
"label": "Url"
}
},
"action": {
"create": ""
"create": "Nová integrace"
},
"testConnection": {
"action": {
"create": "",
"edit": ""
"create": "Otestovat připojení a vytvořit",
"edit": "Otestovat připojení a uložit"
},
"alertNotice": "",
"notification": {
"success": {
"title": "",
"title": "Spojení bylo úspěšné",
"message": ""
},
"invalidUrl": {
"title": "Neplatná URL adresa",
"message": ""
"message": "URL adresa je neplatná"
},
"secretNotDefined": {
"title": "",
"message": ""
"title": "Chybějící přihlašovací údaje",
"message": "Nebyly poskytnuty všechny přihlašovací údaje"
},
"invalidCredentials": {
"title": "",
"message": ""
"title": "Neplatné přihlašovací údaje",
"message": "Přihlašovací údaje jsou neplatné"
},
"commonError": {
"title": "",
"message": ""
"title": "Spojení selhalo",
"message": "Spojení nelze navázat"
},
"badRequest": {
"title": "",
"title": "Chybný požadavek",
"message": ""
},
"unauthorized": {
@@ -766,89 +766,89 @@
}
},
"common": {
"beta": "",
"beta": "Beta",
"error": "Chyba",
"action": {
"add": "Přidat",
"apply": "Použít",
"backToOverview": "",
"backToOverview": "Zpět na přehled",
"create": "Vytvořit",
"edit": "Upravit",
"import": "",
"import": "Importovat",
"insert": "Vložit",
"remove": "Odstranit",
"save": "Uložit",
"saveChanges": "Uložit změny",
"cancel": "Zrušit",
"delete": "Odstranit",
"discard": "",
"discard": "Zahodit",
"confirm": "Potvrdit",
"continue": "",
"continue": "Pokračovat",
"previous": "Zpět",
"next": "Další",
"checkoutDocs": "",
"checkLogs": "",
"checkoutDocs": "Přečtěte si dokumentaci",
"checkLogs": "Pro více podrobností zkontrolujte logy",
"tryAgain": "Zkusit znovu",
"loading": ""
"loading": "Načítání"
},
"here": "",
"here": "zde",
"iconPicker": {
"label": "",
"label": "URL ikony",
"header": ""
},
"colorScheme": {
"options": {
"light": "",
"dark": ""
"light": "Světlý",
"dark": "Tmavý"
}
},
"information": {
"min": "",
"max": "",
"days": "",
"min": "Min",
"max": "Max",
"days": "Dnů",
"hours": "Hodin",
"minutes": "Minut"
},
"notification": {
"create": {
"success": "",
"error": ""
"success": "Vytvoření bylo úspěšné",
"error": "Vytvoření se nezdařilo"
},
"delete": {
"success": "",
"error": ""
"success": "Odstranění bylo úspěšné",
"error": "Odstranění se nezdařilo"
},
"update": {
"success": "",
"error": ""
"success": "Změny byly úspěšně aplikovány",
"error": "Nepodařilo se uložit změny"
},
"transfer": {
"success": "",
"error": ""
"success": "Přenos úspěšný",
"error": "Přenos se nezdařil"
}
},
"multiSelect": {
"placeholder": ""
},
"multiText": {
"placeholder": "",
"addLabel": ""
"placeholder": "Přidat více hodnot",
"addLabel": "Přidat {value}"
},
"select": {
"placeholder": "",
"placeholder": "Vybrat hodnotu",
"badge": {
"recommended": ""
"recommended": "Doporučeno"
}
},
"userAvatar": {
"menu": {
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"switchToDarkMode": "Přepnout do tmavého režimu",
"switchToLightMode": "Přepnout do světlého režimu",
"management": "Správa",
"preferences": "Vaše předvolby",
"logout": "",
"logout": "Odhlásit se",
"login": "Přihlásit se",
"homeBoard": "",
"homeBoard": "Vaše domácí plocha",
"loggedOut": "",
"updateAvailable": ""
}
@@ -856,8 +856,8 @@
"dangerZone": "Nebezpečná zóna",
"noResults": "Nebyly nalezeny žádné výsledky",
"preview": {
"show": "",
"hide": ""
"show": "Zobrazit náhled",
"hide": "Skrýt náhled"
},
"zod": {
"errors": {
@@ -867,7 +867,7 @@
"startsWith": "Toto pole musí začínat {startsWith}",
"endsWith": "Toto pole musí končit {endsWith}",
"includes": "Toto pole musí obsahovat {includes}",
"invalidEmail": ""
"invalidEmail": "Toto pole musí být platný e-mail"
},
"tooSmall": {
"string": "Toto pole musí obsahovat alespoň {minimum} znaků",
@@ -878,13 +878,13 @@
"number": "Toto pole musí být menší nebo rovno {maximum}"
},
"custom": {
"passwordsDoNotMatch": "",
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
"passwordsDoNotMatch": "Hesla se neshodují",
"passwordRequirements": "Heslo nesplňuje požadavky",
"boardAlreadyExists": "Plocha s tímto názvem již existuje",
"invalidFileType": "Neplatný typ souboru, očekáván {expected}",
"fileTooLarge": "Soubor je příliš velký, maximální velikost je {maxSize}",
"invalidConfiguration": "Neplatná konfigurace",
"groupNameTaken": "Název skupiny je již používán"
}
}
}
@@ -892,11 +892,11 @@
"section": {
"dynamic": {
"action": {
"create": "",
"remove": ""
"create": "Nová dynamická sekce",
"remove": "Odstranit dynamickou sekci"
},
"remove": {
"title": "",
"title": "Odstranit dynamickou sekci",
"message": ""
}
},
@@ -1047,7 +1047,7 @@
},
"dnsHoleSummary": {
"name": "",
"description": "",
"description": "Zobrazí shrnutí vaší DNS Hole",
"option": {
"layout": {
"label": "Rozložení",
@@ -1059,16 +1059,16 @@
"label": "Vertikální"
},
"grid": {
"label": ""
"label": "Mřížka"
}
}
},
"usePiHoleColors": {
"label": ""
"label": "Používat barvy Pi-Hole"
}
},
"error": {
"internalServerError": "",
"internalServerError": "Nepodařilo se načíst souhrn DNS Hole",
"integrationsDisconnected": ""
},
"data": {
@@ -1092,7 +1092,7 @@
"label": "Vertikální"
},
"grid": {
"label": ""
"label": "Mřížka"
}
}
},
@@ -1104,21 +1104,21 @@
"internalServerError": ""
},
"controls": {
"enableAll": "",
"disableAll": "",
"setTimer": "",
"enableAll": "Povolit vše",
"disableAll": "Zakázat vše",
"setTimer": "Nastavit časovač",
"set": "Nastavit",
"enabled": "Zapnuto",
"disabled": "Vypnuto",
"processing": "",
"processing": "Zpracovávání",
"disconnected": "",
"hours": "Hodin",
"minutes": "Minut",
"unlimited": ""
"unlimited": "Ponechte prázdné pro neomezené"
}
},
"clock": {
"name": "",
"name": "Datum a čas",
"description": "Zobrazuje aktuální datum a čas.",
"option": {
"customTitleToggle": {
@@ -1126,17 +1126,17 @@
"description": ""
},
"customTitle": {
"label": ""
"label": "Název"
},
"is24HourFormat": {
"label": "",
"description": ""
"label": "24hodinový formát",
"description": "Použít 24hodinový formát místo 12hodinového formátu"
},
"showSeconds": {
"label": ""
"label": "Zobrazit vteřiny"
},
"useCustomTimezone": {
"label": ""
"label": "Použít pevné časové pásmo"
},
"timezone": {
"label": "Časové pásmo",
@@ -1146,7 +1146,7 @@
"label": ""
},
"dateFormat": {
"label": "",
"label": "Formát data",
"description": ""
}
}
@@ -1213,11 +1213,11 @@
}
},
"iframe": {
"name": "",
"name": "iFrame",
"description": "Vložte jakýkoli obsah z internetu. Některé webové stránky mohou omezit přístup.",
"option": {
"embedUrl": {
"label": ""
"label": "Embed URL"
},
"allowFullScreen": {
"label": "Povolit celou obrazovku"
@@ -1245,12 +1245,12 @@
}
},
"error": {
"noUrl": "",
"noUrl": "Nebyla zadána URL adresa iFrame",
"noBrowerSupport": "Váš prohlížeč nepodporuje iFrame. Aktualizujte, prosím, svůj prohlížeč."
}
},
"smartHome-entityState": {
"name": "",
"name": "Stav entity",
"description": "",
"option": {
"entityId": {
@@ -1316,7 +1316,7 @@
"label": ""
},
"hasForecast": {
"label": ""
"label": "Zobrazit předpověď"
},
"forecastDayCount": {
"label": "",
@@ -1400,14 +1400,14 @@
"latitude": "",
"longitude": "",
"disabledTooltip": "",
"unknownLocation": "",
"unknownLocation": "Neznámá poloha",
"search": "Vyhledat",
"table": {
"header": {
"city": "",
"country": "",
"coordinates": "",
"population": ""
"city": "Město",
"country": "Státy",
"coordinates": "Souřadnice",
"population": "Populace"
},
"action": {
"select": ""
@@ -1418,16 +1418,16 @@
}
},
"integration": {
"noData": "",
"description": ""
"noData": "Nebyla nalezena žádná integrace",
"description": "Klikněte na <here></here> pro vytvoření nové integrace"
},
"app": {
"noData": "",
"description": ""
"description": "Klikněte na <here></here> pro vytvoření nové aplikace"
},
"error": {
"noIntegration": "",
"noData": ""
"noIntegration": "Nebyla vybrána žádná integrace",
"noData": "Nejsou k dispozici žádná data o integraci"
},
"option": {}
},
@@ -1440,7 +1440,7 @@
},
"hasAutoPlay": {
"label": "Automatické přehrávání",
"description": ""
"description": "Automatické přehrávání funguje pouze když je ztlumeno kvůli omezením prohlížeče"
},
"isMuted": {
"label": ""
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "Uživatel",
"name": "Jméno",
"id": "Id"
}
},
"downloads": {
"name": "",
@@ -1515,11 +1520,11 @@
"detailsTitle": "Rychlost stahování"
},
"index": {
"columnTitle": "",
"columnTitle": "#",
"detailsTitle": ""
},
"id": {
"columnTitle": ""
"columnTitle": "Id"
},
"integration": {
"columnTitle": "Integrace"
@@ -1536,12 +1541,12 @@
"detailsTitle": ""
},
"received": {
"columnTitle": "",
"detailsTitle": ""
"columnTitle": "Celkem staženo",
"detailsTitle": "Celkem staženo"
},
"sent": {
"columnTitle": "",
"detailsTitle": ""
"columnTitle": "Celkem nahráno",
"detailsTitle": "Celkem nahráno"
},
"size": {
"columnTitle": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1381,7 +1381,7 @@
"memory": "Speicher: {memory}GiB",
"memoryAvailable": "Verfügbar: {memoryAvailable} GiB ({percent}%)",
"version": "",
"uptime": "Betriebszeit: {days} Tage, {hours} Stunden, {minutes} Minuten",
"uptime": "Laufzeit: {months} Monate, {days} Tage, {hours} Stunden, {minutes} Minuten",
"loadAverage": "Durchschnittliche Last:",
"minute": "1 Minute",
"minutes": "{count} Minuten",
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -612,6 +612,10 @@
},
"url": {
"label": "Url"
},
"attemptSearchEngineCreation": {
"label": "Create Search Engine",
"description": "Integration \"{kind}\" can be used with the search engines. Check this to automatically configure the search engine."
}
},
"action": {
@@ -1151,6 +1155,25 @@
}
}
},
"minecraftServerStatus": {
"name": "Minecraft Server Status",
"description": "Displays the status of a Minecraft server",
"option": {
"title": {
"label": "Title"
},
"domain": {
"label": "Server address"
},
"isBedrockServer": {
"label": "Bedrock server"
}
},
"status": {
"online": "Online",
"offline": "Offline"
}
},
"notebook": {
"name": "Notebook",
"description": "A simple notebook widget that supports markdown",
@@ -1381,7 +1404,7 @@
"memory": "Memory: {memory}GiB",
"memoryAvailable": "Available: {memoryAvailable}GiB ({percent}%)",
"version": "Version: {version}",
"uptime": "Uptime: {days} Days, {hours} Hours, {minutes} Minutes",
"uptime": "Uptime: {months} Months, {days} Days, {hours} Hours, {minutes} Minutes",
"loadAverage": "Load average:",
"minute": "1 minute",
"minutes": "{count} minutes",
@@ -1457,7 +1480,12 @@
"mediaServer": {
"name": "Current media server streams",
"description": "Show the current streams on your media servers",
"option": {}
"option": {},
"items": {
"user": "User",
"name": "Name",
"id": "Id"
}
},
"downloads": {
"name": "Download Client",
@@ -2319,6 +2347,9 @@
"error": "Error"
},
"job": {
"minecraftServerStatus": {
"label": "Minecraft server status"
},
"iconsUpdater": {
"label": "Icons Updater"
},
@@ -2717,6 +2748,11 @@
}
}
},
"media": {
"requestMovie": "Request movie",
"requestSeries": "Request series",
"openIn": "Open in {kind}"
},
"external": {
"help": "Use an external search engine",
"group": {
@@ -2957,6 +2993,22 @@
}
}
}
},
"media": {
"request": {
"modal": {
"title": "Request \"{name}\"",
"table": {
"header": {
"season": "Season",
"episodes": "Episodes"
}
},
"button": {
"send": "Send request"
}
}
}
}
}
}

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

File diff suppressed because it is too large Load Diff

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -2,16 +2,16 @@
"init": {
"step": {
"start": {
"title": "",
"title": "Homarrへようこそ",
"subtitle": "",
"description": "",
"action": {
"scratch": "",
"scratch": "ゼロからスタート",
"importOldmarr": ""
}
},
"import": {
"title": "",
"title": "データのインポート",
"subtitle": "",
"dropzone": {
"title": "",
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

File diff suppressed because it is too large Load Diff

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1254,7 +1254,7 @@
"description": "",
"option": {
"entityId": {
"label": "ID encji"
"label": "ID obiektu"
},
"displayName": {
"label": ""
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1381,7 +1381,7 @@
"memory": "Память: {memory}GiB",
"memoryAvailable": "Доступно: {memoryAvailable}GiB ({percent}%)",
"version": "Версия: {version}",
"uptime": "Время работы: {days} дн., {hours} ч., {minutes} мин.",
"uptime": "Время работы: {months} мес., {days} дн., {hours} ч., {minutes} мин.",
"loadAverage": "Средняя нагрузка:",
"minute": "1 минута",
"minutes": "{count} минут",
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "Активные просмотры",
"description": "Отображает текущие сеансы воспроизведения на медиасерверах",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "Загрузки",

View File

@@ -2,9 +2,9 @@
"init": {
"step": {
"start": {
"title": "Vitajte na stránke Homarr",
"subtitle": "Začnime s nastavením inštancie Homarr.",
"description": "Ak chcete začať, vyberte, ako chcete nastaviť inštanciu Homarr.",
"title": "Vitajte na Homár",
"subtitle": "Začnime s nastavením inštancie Homár.",
"description": "Ak chcete začať, vyberte, ako chcete nastaviť inštanciu Homár.",
"action": {
"scratch": "Začať odznova",
"importOldmarr": "Import z Homarru pred 1.0"
@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "Aktuálne streamy mediálneho servera",
"description": "Zobrazte aktuálne streamy na vašich mediálnych serveroch",
"option": {}
"option": {},
"items": {
"user": "Používateľ",
"name": "Názov",
"id": "Id"
}
},
"downloads": {
"name": "Download klient",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -147,7 +147,7 @@
"label": "Eski Şifre"
},
"homeBoard": {
"label": "Varsayılan Panel"
"label": "Öntanımlı panel"
},
"pingIconsEnabled": {
"label": "Pingler İçin Simgeler Kullanın"
@@ -203,10 +203,10 @@
"changeHomeBoard": {
"notification": {
"success": {
"message": "Varsayılan panel başarıyla değiştirildi"
"message": "Öntanımlı panel başarıyla değiştirildi"
},
"error": {
"message": "Varsayılan panel değiştirilemiyor"
"message": "Öntanımlı panel değiştirilemiyor"
}
}
},
@@ -498,7 +498,7 @@
}
},
"create": {
"title": "Yeni uygulama",
"title": "Yeni Uygulama",
"notification": {
"success": {
"title": "Oluşturma başarılı",
@@ -566,7 +566,7 @@
}
},
"create": {
"title": "Yeni {name} entegrasyonu",
"title": "Yeni {name} Entegrasyonu",
"notification": {
"success": {
"title": "Oluşturma başarılı",
@@ -615,7 +615,7 @@
}
},
"action": {
"create": "Yeni entegrasyon"
"create": "Yeni Entegrasyon"
},
"testConnection": {
"action": {
@@ -703,13 +703,13 @@
"message": "Gizli anahtarı sıfırlamak istediğinizden emin misiniz?"
},
"noSecretsRequired": {
"segmentTitle": "Hiçbir Gizli Anahtar Bulunamadı",
"segmentTitle": "Gizli Anahtar Yok",
"text": "Bu entegrasyon için gizli anahtar gerekmiyor"
},
"kind": {
"username": {
"label": "Kullanıcı adı",
"newLabel": "Yeni kullanıcı adı"
"newLabel": "Yeni Kullanıcı Adı"
},
"apiKey": {
"label": "API Anahtarı",
@@ -717,7 +717,7 @@
},
"password": {
"label": "Şifre",
"newLabel": "Yeni parola"
"newLabel": "Yeni Şifre"
}
}
},
@@ -842,18 +842,18 @@
},
"userAvatar": {
"menu": {
"switchToDarkMode": "Koyu moda geç",
"switchToLightMode": "Aydınlık moda geç",
"switchToDarkMode": "Koyu Temaya Geç",
"switchToLightMode": "Aydınlık Temaya Geç",
"management": "Yönetim",
"preferences": "Tercihleriniz",
"logout": "Oturumu kapat",
"login": "Giriş",
"homeBoard": "Varsayılan Panel",
"homeBoard": "Öntanımlı Panel",
"loggedOut": "Oturum kapatıldı",
"updateAvailable": "{countUpdates} güncelleme mevcut: {tag}"
}
},
"dangerZone": "Tehlikeli bölge",
"dangerZone": "Tehlikeli Bölge",
"noResults": "Sonuç bulunamadı",
"preview": {
"show": "Ara Renkleri Göster",
@@ -892,7 +892,7 @@
"section": {
"dynamic": {
"action": {
"create": "Yeni dinamik bölüm",
"create": "Yeni Dinamik Bölüm",
"remove": "Dinamik bölümü kaldır"
},
"remove": {
@@ -907,16 +907,16 @@
}
},
"action": {
"create": "Yeni kategori",
"edit": "Kategoriyi yeniden adlandır",
"create": "Yeni Kategori",
"edit": "Kategoriyi Yeniden Adlandır",
"remove": "Kategoriyi kaldır",
"moveUp": "Yukarı taşı",
"moveDown": "Aşağı taşı",
"createAbove": "Yukarı yeni kategori",
"createBelow": "Aşağı yeni kategori"
"createAbove": "Yukarı Yeni Kategori",
"createBelow": "Aşağı Yeni Kategori"
},
"create": {
"title": "Yeni kategori",
"title": "Yeni Kategori",
"submit": "Kategori ekle"
},
"remove": {
@@ -924,12 +924,12 @@
"message": "{name} kategorisini kaldırmak istediğinizden emin misiniz?"
},
"edit": {
"title": "Kategoriyi yeniden adlandır",
"submit": "Kategoriyi yeniden adlandır"
"title": "Kategoriyi Yeniden Adlandır",
"submit": "Kategoriyi Yeniden Adlandır"
},
"menu": {
"label": {
"create": "Yeni kategori",
"create": "Yeni Kategori",
"changePosition": "Pozisyonu değiştir"
}
}
@@ -937,10 +937,10 @@
},
"item": {
"action": {
"create": "Yeni öğe",
"create": "Yeni Öğe",
"import": "Öğeleri İçe Aktar",
"edit": "Öğeyi Düzenle",
"moveResize": "Öğeyi tı / yeniden boyutlandır",
"moveResize": "Öğeyi Tı / Yeniden Boyutlandır",
"duplicate": "Öğeyi Çoğalt",
"remove": "Öğeyi kaldır"
},
@@ -954,7 +954,7 @@
"addToBoard": "Panele Ekle"
},
"moveResize": {
"title": "Öğeyi tı / yeniden boyutlandır",
"title": "Öğeyi Tı / Yeniden Boyutlandır",
"field": {
"width": {
"label": "Genişlik"
@@ -981,7 +981,7 @@
"label": "Entegrasyonlar"
},
"customCssClasses": {
"label": "Özel css sınıfları"
"label": "Özel CSS Alanı"
}
}
},
@@ -999,7 +999,7 @@
"label": "Uygulama seç"
},
"openInNewTab": {
"label": "Yeni sekmede aç"
"label": "Yeni Sekmede Aç"
},
"showTitle": {
"label": "Uygulama adını göster"
@@ -1019,7 +1019,7 @@
}
},
"bookmarks": {
"name": "Yer imleri",
"name": "Yer İmleri",
"description": "Birden fazla uygulama bağlantısını görüntüler",
"option": {
"title": {
@@ -1040,7 +1040,7 @@
}
},
"items": {
"label": "Yer imleri",
"label": "Yer İmleri",
"add": "Yer imi ekle"
}
}
@@ -1079,7 +1079,7 @@
}
},
"dnsHoleControls": {
"name": "DNS çözümleyici kontrolleri",
"name": "DNS Çözümleyici Kontrolleri",
"description": "Kontrol panelinizden PiHole veya AdGuard'ı kontrol edin",
"option": {
"layout": {
@@ -1118,7 +1118,7 @@
}
},
"clock": {
"name": "Tarih ve saat",
"name": "Tarih ve Saat",
"description": "Geçerli tarih ve saati görüntüler.",
"option": {
"customTitleToggle": {
@@ -1152,7 +1152,7 @@
}
},
"notebook": {
"name": "Not defteri",
"name": "Not Defteri",
"description": "Markdown'ı destekleyen basit bir not defteri bileşeni",
"option": {
"showToolbar": {
@@ -1345,17 +1345,17 @@
}
},
"indexerManager": {
"name": "Dizin oluşturucu yöneticisi statüsü",
"description": "Dizinleyicilerinizin durumu",
"name": "İndeksleyici Yönetim Durumu",
"description": "İndeksleyicilerinizin durumu",
"option": {
"openIndexerSiteInNewTab": {
"label": "Dizinleyici sitesini yeni sekmede aç"
"label": "İndeksleyici Sitesini Yeni Sekmede Aç"
}
},
"title": "Dizin oluşturucu yöneticisi",
"title": "İndeksleyici Yöneticisi",
"testAll": "Tümünü test et",
"error": {
"internalServerError": "Dizinleyicilerin durumu alınamadı"
"internalServerError": "İndeksleyicilerin durumu alınamadı"
}
},
"healthMonitoring": {
@@ -1455,9 +1455,14 @@
}
},
"mediaServer": {
"name": "Güncel medya sunucusu akışları",
"name": "Güncel Medya Sunucusu Akışları",
"description": "Medya sunucularınızdaki mevcut akışları gösterin",
"option": {}
"option": {},
"items": {
"user": "Kullanıcı",
"name": "İsim",
"id": "Kimlik"
}
},
"downloads": {
"name": "İndirme İstemcisi",
@@ -1470,7 +1475,7 @@
"label": "Öğelerin sıralanmasını etkinleştir"
},
"defaultSort": {
"label": "Varsayılan olarak sıralama için kullanılan sütun"
"label": "Öntanımlı olarak sıralama için kullanılan sütun"
},
"descendingDefaultSort": {
"label": "Ters sıralama"
@@ -1644,11 +1649,11 @@
}
},
"mediaTranscoding": {
"name": "Medya kod dönüştürme",
"name": "Medya Kod Dönüştürme",
"description": "Medya kod dönüştürmenizin istatistikleri, geçerli kuyruğu ve çalışan durumu",
"option": {
"defaultView": {
"label": "Varsayılan görünüm"
"label": "Öntanımlı görünüm"
},
"queuePageSize": {
"label": "Kuyruk Sayfa Boyutu"
@@ -1703,8 +1708,8 @@
}
},
"rssFeed": {
"name": "RSS beslemeleri",
"description": "Bir veya daha fazla genel RSS, ATOM veya JSON beslemesini izleyin ve görüntüleyin",
"name": "RSS Beslemeleri",
"description": "Bir veya daha fazla RSS, ATOM veya JSON beslemesini takip edin ve görüntüleyin",
"option": {
"feedUrls": {
"label": "Besleme URL'leri"
@@ -1807,22 +1812,22 @@
},
"field": {
"pageTitle": {
"label": "Sayfa başlığı"
"label": "Sayfa Başlığı"
},
"metaTitle": {
"label": "Meta başlığı"
"label": "Meta Başlığı"
},
"logoImageUrl": {
"label": "Logo görsel URL'si"
"label": "Logo Görseli URL'si"
},
"faviconImageUrl": {
"label": "Favicon görsel URL'si"
"label": "Favicon Görseli URL'si"
},
"backgroundImageUrl": {
"label": "Arkaplan görsel URL'si"
"label": "Arkaplan Resmi URL'si"
},
"backgroundImageAttachment": {
"label": "Arkaplan görsel yerleştirme",
"label": "Arka Plan Yerleşimi",
"option": {
"fixed": {
"label": "Sabit",
@@ -1830,12 +1835,12 @@
},
"scroll": {
"label": "Kaydır",
"description": "Arkaplan farenizle birlikte kayar."
"description": "Arkaplan Farenizle Birlikte Kayar."
}
}
},
"backgroundImageRepeat": {
"label": "Arkaplan görüntüsünü Tekrarla",
"label": "Arkaplan Resmini Tekrarla",
"option": {
"repeat": {
"label": "Tekrarla",
@@ -1856,7 +1861,7 @@
}
},
"backgroundImageSize": {
"label": "Arkaplan görsel boyutu",
"label": "Arkaplan Resim Boyutu",
"option": {
"cover": {
"label": "Kapak",
@@ -1879,14 +1884,14 @@
},
"customCss": {
"label": "Bu panel için özel css",
"description": "Kontrol panelinizi yalnızca deneyimli kullanıcılar için önerilen CSS kullanarak özelleştirin",
"description": "CSS kullanarak panelizi özelleştirin, sadece tecrübeli kullanıcılar için tavsiye edilir",
"customClassesAlert": {
"title": "Özel sınıflar",
"description": "Her öğenin gelişmiş seçeneklerinde panel örneklerinize özel sınıflar ekleyebilir ve bunları yukarıdaki özel CSS'de kullanabilirsiniz."
"description": "Düzenlenen her aracın gelişmiş seçenekler sekmesinde bulunan alanı kullanarak panel öğelerinizi CSS ile özelleştirebilir ve ya yukarıdaki özel CSS alanını kullanabilirsiniz."
}
},
"columnCount": {
"label": "Sütun sayısı"
"label": "Sütun Sayısı"
},
"name": {
"label": "İsim"
@@ -1919,7 +1924,7 @@
"title": "Özel Css"
},
"access": {
"title": "Erişim kontrolü",
"title": "Erişim Kontrolü",
"permission": {
"item": {
"view": {
@@ -1935,14 +1940,14 @@
}
},
"dangerZone": {
"title": "Tehlikeli bölge",
"title": "Tehlikeli Bölge",
"action": {
"rename": {
"label": "Panelin adını değiştir",
"label": "Panelin ismini değiştir",
"description": "İsmin değiştirilmesi bu panele olan tüm bağlantıların kopmasına neden olacaktır.",
"button": "İsmi değiştir",
"modal": {
"title": "Panelin adını değiştir"
"title": "Panelin ismini değiştir"
}
},
"visibility": {
@@ -1993,7 +1998,7 @@
"notice": "Bağlantıyı kontrol edin veya erişilebilir olması gerektiğini düşünüyorsanız bir yöneticiyle iletişime geçin"
},
"homeBoard": {
"title": "Varsayılan Panel Yapılandırılmadı",
"title": "Öntanımlı Panel Yapılandırılmadı",
"admin": {
"description": "Sunucu için henüz bir ana sayfa paneli belirlemediniz.",
"link": "Sunucu geneli için ana panel yapılandırın",
@@ -2001,13 +2006,13 @@
},
"user": {
"description": "Henüz bir ev paneli belirlemediniz.",
"link": "Varsayılan Paneli Yapılandır",
"notice": "Bu Sayfanın Kaybolmasını Sağlamak İçin Tercihlerden Varsayılan Panel Atayın"
"link": "Öntanımlı paneli yapılandır",
"notice": "Bu sayfanın kaybolmasını sağlamak için tercihlerden öntanımlı panel seçin"
},
"anonymous": {
"description": "Sunucu Yöneticiniz Henüz Bir Varsayılan Panel Atamadı.",
"description": "Sunucu yöneticiniz henüz bir öntanımlı panel belirlemedi.",
"link": "Herkese açık panelleri görüntüle",
"notice": "Bu Sayfanın Kaybolmasını Sağlamak İçin Sunucu Yöneticisinden Varsayılan Panel Atamasını isteyin"
"notice": "Bu sayfanın kaybolmasını sağlamak için sunucu yöneticisinden öntanımlı panel ayarlamasını isteyin"
}
}
}
@@ -2082,7 +2087,7 @@
"title": "Panelleriniz",
"action": {
"new": {
"label": "Yeni panel"
"label": "Yeni Panel"
},
"open": {
"label": "Panel'i aç"
@@ -2091,10 +2096,10 @@
"label": "Ayarlar"
},
"setHomeBoard": {
"label": "Varsayılan Panel Olarak Ata",
"label": "Öntanımlı panel olarak ayarlayın",
"badge": {
"label": "Ana Sayfa",
"tooltip": "Bu panel sizin varsayılan paneliniz olarak görüntülenecektir"
"tooltip": "Bu panel sizin öntanımlı paneliniz olarak görüntülenecektir"
}
},
"delete": {
@@ -2130,7 +2135,7 @@
"title": "Genel",
"item": {
"language": "Dil & Bölge",
"board": "Varsayılan Panel",
"board": "Öntanımlı panel",
"firstDayOfWeek": "Haftanın ilk günü",
"accessibility": "Erişilebilirlik"
}
@@ -2151,7 +2156,7 @@
},
"create": {
"metaTitle": "Kullanıcı ekle",
"title": "Yeni kullanıcı oluştur",
"title": "Yeni Kullanıcı Oluştur",
"step": {
"personalInformation": {
"label": "Kişisel bilgiler"
@@ -2183,7 +2188,7 @@
"title": "Kullanıcı Davetlerini Yönet",
"action": {
"new": {
"title": "Yeni davet",
"title": "Yeni Davet",
"description": "Süre sona erdikten sonra davet artık geçerli olmayacak ve daveti alan kişi bir hesap oluşturamayacaktır."
},
"copy": {
@@ -2254,11 +2259,11 @@
},
"widgetData": {
"title": "Widget verileri",
"text": "Widget (ve sayıları) yapılandırma verilerini gönder. URL'leri, adları veya başka herhangi bir veriyi içermez."
"text": "Araç (ve sayılarını) yapılandırma verilerini gönder. URL'leri, alan adlarını veya başka herhangi bir veriyi içermez."
},
"integrationData": {
"title": "Entegrasyon verileri",
"text": "Entegrasyon (ve sayıları) yapılandırma verilerini gönder. URL'leri, adları veya başka herhangi bir veriyi içermez."
"text": "Entegrasyon (ve sayıları) yapılandırma verilerini gönder. URL'leri, alan adlarını veya başka herhangi bir veriyi içermez."
},
"usersData": {
"title": "Kullanıcı verileri",
@@ -2281,21 +2286,21 @@
"text": "Kullanıcının site üzerinde okumak isteyeceği dilin olmaması durumunda Google, arama sonuçlarında bir çeviri bağlantısı gösterecektir"
},
"noSiteLinksSearchBox": {
"title": "Site arama alanını gösterme",
"title": "Site Arama Alanını Gösterme",
"text": "Google, aranan bağlantıların yanı sıra diğer doğrudan bağlantılardan oluşan bir arama kutusu oluşturur. Bu seçenek etkinleştirildiğinde Google'dan bu alanı devre dışı bırakması istenecektir."
}
},
"board": {
"title": "Paneller",
"homeBoard": {
"label": "Genel Varsayılan Panel",
"description": "Seçim için yalnızca herkese açık paneller kullanılabilir"
"label": "Genel Ana Sayfa Paneli",
"description": "Seçim yalnızca herkese açık paneller için kullanılabilir"
}
},
"appearance": {
"title": "Görünüş",
"defaultColorScheme": {
"label": "Varsayılan renk şeması",
"label": "Öntanımlı Renk Düzeni",
"options": {
"light": "Aydınlık",
"dark": "Koyu"
@@ -2305,7 +2310,7 @@
"culture": {
"title": "Kültür",
"defaultLocale": {
"label": "Varsayılan dil"
"label": "Öntanımlı Dil"
}
}
}
@@ -2320,7 +2325,7 @@
},
"job": {
"iconsUpdater": {
"label": "Simge Güncelleyici"
"label": "Simge Güncelleme"
},
"analytics": {
"label": "Analiz"
@@ -2347,10 +2352,10 @@
"label": "Medya Talep Listesi"
},
"rssFeeds": {
"label": "RSS beslemeleri"
"label": "RSS Beslemeleri"
},
"indexerManager": {
"label": "Dizinleyici Yöneticisi"
"label": "İndeksleyici Yöneticisi"
},
"healthMonitoring": {
"label": "Sağlık İzleme"
@@ -2365,7 +2370,7 @@
"label": "Güncelleme denetleyicisi"
},
"mediaTranscoding": {
"label": "Medya kod dönüştürme"
"label": "Medya Kod Dönüştürme"
}
}
},
@@ -2400,7 +2405,7 @@
},
"about": {
"version": "Sürüm {version}",
"text": "Homarr, gönüllüler tarafından sürdürülen topluluk odaklı bir açık kaynak projesidir. Bu kişiler sayesinde Homarr, 2021'den beri büyüyen bir projedir. Ekibimiz, boş zamanlarında Homarr üzerinde birçok farklı ülkeden tamamen uzaktan, hiçbir ücret almadan çalışmaktadır.",
"text": "Homarr, gönüllüler tarafından sürdürülen topluluk odaklı bir açık kaynak projesidir. Bu kişiler sayesinde Homarr, 2021'den bugüne büyüyerek ilerleyen bir projedir. Ekibimiz, boş zamanlarında Homarr üzerinde birçok farklı ülkeden tamamen uzaktan, hiçbir ücret almadan çalışmaktadır.",
"accordion": {
"contributors": {
"title": "Katkıda Bulunanlar",
@@ -2408,7 +2413,7 @@
},
"translators": {
"title": "Çevirmenler",
"subtitle": "{count} dilde birçok kişi çevirilere katkıda bulunuyor"
"subtitle": "Birçok kişi Homarr'ın {count} dile çevrilmesine katkı sağlıyor"
},
"libraries": {
"title": "Kütüphaneler",
@@ -2435,7 +2440,7 @@
"created": "Oluşturuldu",
"running": "Çalışıyor",
"paused": "Duraklatıldı",
"restarting": "Yeniden başlatılıyor",
"restarting": "Yeniden Başlatılıyor",
"exited": ıkıldı",
"removing": "Kaldırılıyor",
"dead": "Ölü"
@@ -2564,7 +2569,7 @@
}
},
"search-engines": {
"label": "Arama motorları",
"label": "Arama Motorları",
"new": {
"label": "Yeni"
},
@@ -2617,7 +2622,7 @@
}
},
"search": {
"placeholder": "Ara",
"placeholder": "Ara (\"!\" İle Başlamalı)",
"nothingFound": "Hiçbir şey bulunamadı",
"error": {
"fetch": "Veriler alınırken bir hata oluştu"
@@ -2650,7 +2655,7 @@
"label": "Panel'i aç"
},
"homeBoard": {
"label": "Varsayılan Panel Olarak Ata"
"label": "Öntanımlı panel olarak ata"
},
"settings": {
"label": "Ayarları aç"
@@ -2676,8 +2681,8 @@
"title": "Genel komutlar",
"option": {
"colorScheme": {
"light": "Aydınlık moda geç",
"dark": "Koyu moda geç"
"light": "Aydınlık Temaya Geç",
"dark": "Koyu Temaya Geç"
},
"language": {
"label": "Dili değiştir",
@@ -2688,16 +2693,16 @@
}
},
"newBoard": {
"label": "Yeni bir panel oluştur"
"label": "Yeni Panel Oluştur"
},
"importBoard": {
"label": "Bir paneli içe aktar"
},
"newApp": {
"label": "Yeni bir uygulama oluştur"
"label": "Yeni Uygulama Oluştur"
},
"newIntegration": {
"label": "Yeni bir entegrasyon oluşturun",
"label": "Yeni Entegrasyon Oluştur",
"children": {
"detail": {
"title": "Oluşturmak istediğiniz entegrasyon türünü seçin"
@@ -2705,13 +2710,13 @@
}
},
"newUser": {
"label": "Yeni bir kullanıcı oluştur"
"label": "Yeni Kullanıcı Oluştur"
},
"newInvite": {
"label": "Yeni bir davet oluştur"
"label": "Yeni Davet Oluştur"
},
"newGroup": {
"label": "Yeni bir grup oluştur"
"label": "Yeni Grup Oluştur"
}
}
}
@@ -2721,7 +2726,7 @@
"help": "Harici bir arama motoru kullanın",
"group": {
"searchEngine": {
"title": "Arama motorları",
"title": "Arama Motorları",
"children": {
"action": {
"search": {
@@ -2729,7 +2734,7 @@
}
},
"detail": {
"title": "Arama motoru için bir eylem seçin"
"title": "Arama yapmak istediğiniz terimleri girin"
},
"searchResults": {
"title": "Eylemler için bir arama sonucu seçin"
@@ -2840,7 +2845,7 @@
"label": "Hakkında"
},
"homeBoard": {
"label": "Varsayılan Panel"
"label": "Öntanımlı panel"
},
"preferences": {
"label": "Tercihleriniz"
@@ -2913,7 +2918,7 @@
"interactive": "Etkileşimli, entegrasyon kullanır"
},
"create": {
"title": "Yeni arama motoru",
"title": "Yeni Arama Motoru",
"notification": {
"success": {
"title": "Arama motoru oluşturuldu",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "使用者",
"name": "名稱",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -1457,7 +1457,12 @@
"mediaServer": {
"name": "",
"description": "",
"option": {}
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
}
},
"downloads": {
"name": "",

View File

@@ -15,8 +15,21 @@ const searchSchema = z.object({
limit: z.number().int().positive().default(10),
});
const mediaRequestOptionsSchema = z.object({
mediaId: z.number(),
mediaType: z.enum(["tv", "movie"]),
});
const requestMediaSchema = z.object({
mediaType: z.enum(["tv", "movie"]),
mediaId: z.number(),
seasons: z.array(z.number().min(0)).optional(),
});
export const commonSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
search: searchSchema,
mediaRequestOptions: mediaRequestOptionsSchema,
requestMedia: requestMediaSchema,
};

View File

@@ -15,6 +15,7 @@ const integrationCreateSchema = z.object({
value: z.string().nonempty(),
}),
),
attemptSearchEngineCreation: z.boolean(),
});
const integrationUpdateSchema = z.object({

View File

@@ -44,21 +44,21 @@
"@mantine/core": "^7.15.2",
"@mantine/hooks": "^7.15.2",
"@tabler/icons-react": "^3.26.0",
"@tiptap/extension-color": "2.10.4",
"@tiptap/extension-highlight": "2.10.4",
"@tiptap/extension-image": "2.10.4",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-table": "2.10.4",
"@tiptap/extension-table-cell": "2.10.4",
"@tiptap/extension-table-header": "2.10.4",
"@tiptap/extension-table-row": "2.10.4",
"@tiptap/extension-task-item": "2.10.4",
"@tiptap/extension-task-list": "2.10.4",
"@tiptap/extension-text-align": "2.10.4",
"@tiptap/extension-text-style": "2.10.4",
"@tiptap/extension-underline": "2.10.4",
"@tiptap/react": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@tiptap/extension-color": "2.11.0",
"@tiptap/extension-highlight": "2.11.0",
"@tiptap/extension-image": "2.11.0",
"@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-table": "2.11.0",
"@tiptap/extension-table-cell": "2.11.0",
"@tiptap/extension-table-header": "2.11.0",
"@tiptap/extension-table-row": "2.11.0",
"@tiptap/extension-task-item": "2.11.0",
"@tiptap/extension-task-list": "2.11.0",
"@tiptap/extension-text-align": "2.11.0",
"@tiptap/extension-text-style": "2.11.0",
"@tiptap/extension-underline": "2.11.0",
"@tiptap/react": "^2.11.0",
"@tiptap/starter-kit": "^2.11.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.7",

View File

@@ -66,7 +66,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
}
const newData = prevData.map((item) =>
item.integrationId === data.integrationId
? { ...item, healthInfo: data.healthInfo, timestamp: data.timestamp }
? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp }
: item,
);
return newData;
@@ -272,11 +272,12 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const months = uptimeDuration.months();
const days = uptimeDuration.days();
const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", { days, hours, minutes });
return t("widget.healthMonitoring.popover.uptime", { months, days, hours, minutes });
};
export const progressColor = (percentage: number) => {

View File

@@ -22,6 +22,7 @@ import * as mediaRequestsList from "./media-requests/list";
import * as mediaRequestsStats from "./media-requests/stats";
import * as mediaServer from "./media-server";
import * as mediaTranscoding from "./media-transcoding";
import * as minecraftServerStatus from "./minecraft/server-status";
import * as notebook from "./notebook";
import type { WidgetOptionDefinition } from "./options";
import * as rssFeed from "./rssFeed";
@@ -54,6 +55,7 @@ export const widgetImports = {
indexerManager,
healthMonitoring,
mediaTranscoding,
minecraftServerStatus,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -1,12 +1,17 @@
"use client";
import { useMemo } from "react";
import { Avatar, Box, Group, Text } from "@mantine/core";
import type { MantineStyleProp } from "@mantine/core";
import { Avatar, Box, Flex, Group, Stack, Text, Title } from "@mantine/core";
import { IconDeviceAudioTape, IconDeviceTv, IconMovie, IconVideo } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { getIconUrl, integrationDefs } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
@@ -29,26 +34,54 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
{
accessorKey: "sessionName",
header: "Name",
mantineTableHeadCellProps: {
style: {
fontSize: "7cqmin",
padding: "2cqmin",
width: "30%",
},
},
Cell: ({ row }) => (
<Text size="7cqmin" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{row.original.sessionName}
</Text>
),
},
{
accessorKey: "user.username",
header: "User",
mantineTableHeadCellProps: {
style: {
fontSize: "7cqmin",
padding: "2cqmin",
width: "25%",
},
},
Cell: ({ row }) => (
<Group gap={"xs"}>
<Avatar src={row.original.user.profilePictureUrl} size={"sm"} />
<Text>{row.original.user.username}</Text>
<Group gap={"2cqmin"}>
<Avatar src={row.original.user.profilePictureUrl} size={"10cqmin"} />
<Text size="7cqmin">{row.original.user.username}</Text>
</Group>
),
},
{
accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
header: "Currently playing",
mantineTableHeadCellProps: {
style: {
fontSize: "7cqmin",
padding: "2cqmin",
width: "45%",
},
},
Cell: ({ row }) => {
if (row.original.currentlyPlaying) {
return (
<div>
<span>{row.original.currentlyPlaying.name}</span>
</div>
<Box>
<Text size="7cqmin" style={{ whiteSpace: "normal" }}>
{row.original.currentlyPlaying.name}
</Text>
</Box>
);
}
@@ -83,49 +116,153 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
// Only render the flat list of sessions when the currentStreams change
// Otherwise it will always create a new array reference and cause the table to re-render
const flatSessions = useMemo(() => currentStreams.flatMap((pair) => pair.sessions), [currentStreams]);
const flatSessions = useMemo(
() =>
currentStreams.flatMap((pair) =>
pair.sessions.map((session) => ({
...session,
integrationKind: pair.integrationKind,
integrationName: integrationDefs[pair.integrationKind].name,
integrationIcon: getIconUrl(pair.integrationKind),
})),
),
[currentStreams],
);
const baseStyle: MantineStyleProp = {
"--total-width": "calc(100cqw / var(--total-width))",
"--ratio-width": "calc(100cqw / var(--total-width))",
"--space-size": "calc(var(--ratio-width) * 0.1)", //Standard gap and spacing value
"--text-fz": "calc(var(--ratio-width) * 0.45)", //General Font Size
"--icon-size": "calc(var(--ratio-width) * 2 / 3)", //Normal icon size
"--mrt-base-background-color": "transparent",
};
const { openModal } = useModalAction(itemInfoModal);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
enableRowSelection: false,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableSorting: false,
enableColumnActions: false,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
enableGlobalFilter: false,
enableDensityToggle: false,
enableFilters: false,
enablePagination: true,
enableSorting: true,
enableHiding: false,
enableTopToolbar: false,
enableColumnActions: false,
enableStickyHeader: true,
initialState: {
density: "xs",
},
mantinePaperProps: {
display: "flex",
h: "100%",
flex: 1,
withBorder: false,
style: {
flexDirection: "column",
},
shadow: undefined,
},
mantineTableProps: {
className: "media-server-widget-table",
style: {
tableLayout: "fixed",
},
},
mantineTableContainerProps: {
style: {
flexGrow: 5,
height: "100%",
},
},
mantineTableBodyCellProps: ({ row }) => ({
onClick: () => {
openModal({
item: row.original,
title:
row.original.currentlyPlaying?.type === "movie" ? (
<IconMovie size={36} />
) : row.original.currentlyPlaying?.type === "tv" ? (
<IconDeviceTv size={36} />
) : row.original.currentlyPlaying?.type === "video" ? (
<IconVideo size={36} />
) : (
<IconDeviceAudioTape size={36} />
),
});
},
}),
});
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
const session = flatSessions.find((session) => session.integrationKind === kind);
return {
integrationKind: kind,
integrationIcon: session?.integrationIcon,
integrationName: session?.integrationName,
};
});
return (
<Box h="100%">
<Stack gap={0} h="100%" display="flex" style={baseStyle}>
<MantineReactTable table={table} />
</Box>
<Group
gap="1cqmin"
h="var(--ratio-width)"
px="var(--space-size)"
pr="5cqmin"
justify="flex-end"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
>
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="1cqmin" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Stack>
);
}
const itemInfoModal = createModal<{ item: StreamSession; title: React.ReactNode }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
return (
<Stack align="center">
<Flex direction="column" gap="xs" align="center">
<Title>{innerProps.title}</Title>
<Title>{innerProps.item.currentlyPlaying?.name}</Title>
<Group display="flex">
<Title order={3}>{innerProps.item.currentlyPlaying?.episodeName}</Title>
{innerProps.item.currentlyPlaying?.seasonName && (
<>
{" - "}
<Title order={3}>{innerProps.item.currentlyPlaying.seasonName}</Title>
</>
)}
</Group>
</Flex>
<NormalizedLine itemKey={t("user")} value={innerProps.item.user.username} />
<NormalizedLine itemKey={t("name")} value={innerProps.item.sessionName} />
<NormalizedLine itemKey={t("id")} value={innerProps.item.sessionId} />
</Stack>
);
}).withOptions({
defaultTitle() {
return "";
},
size: "auto",
centered: true,
});
const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: string }) => {
return (
<Group w="100%" align="top" justify="space-between">
<Text>{itemKey}:</Text>
<Text>{value}</Text>
</Group>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import { Box, Flex, Group, Text, Tooltip } from "@mantine/core";
import { IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
const [{ data }] = clientApi.widget.minecraft.getServerStatus.useSuspenseQuery(options);
const utils = clientApi.useUtils();
clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, {
onData(data) {
utils.widget.minecraft.getServerStatus.setData(options, {
data,
timestamp: new Date(),
});
},
});
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
const title = options.title.trim().length > 0 ? options.title : options.domain;
return (
<Flex
className="minecraftServerStatus-wrapper"
h="100%"
w="100%"
direction="column"
p="7.5cqmin"
justify="center"
align="center"
>
<Group gap="5cqmin" wrap="nowrap" align="center">
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
<Box w="8cqmin" h="8cqmin" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
</Tooltip>
<Text size="10cqmin" fw="bold">
{title}
</Text>
</Group>
{data.online && (
<>
<img
style={{ flex: 1, transform: "scale(0.8)", objectFit: "contain" }}
alt={`minecraft icon ${options.domain}`}
src={data.icon}
/>
<Group gap="2cqmin" c="gray.6" align="center">
<IconUsersGroup style={{ width: "10cqmin", height: "10cqmin" }} />
<Text size="10cqmin">
{data.players.online}/{data.players.max}
</Text>
</Group>
</>
)}
</Flex>
);
}

View File

@@ -0,0 +1,15 @@
import { IconBrandMinecraft } from "@tabler/icons-react";
import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
options: optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }),
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
isBedrockServer: factory.switch({ defaultValue: false }),
})),
}).withDynamicImport(() => import("./component"));

681
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,25 @@
#!/bin/sh
set -e
# Creating folders in volume
mkdir -p /appdata/db
mkdir -p /appdata/redis
export PUID=${PUID:-0}
export PGID=${PGID:-0}
chown -R nextjs:nodejs /appdata
echo "Starting with UID='$PUID', GID='$PGID'"
su-exec 1001:1001 "$@"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
chown -R $PUID:$PGID /var/cache/nginx
chown -R $PUID:$PGID /var/log/nginx
chown -R $PUID:$PGID /var/lib/nginx
chown -R $PUID:$PGID /run/nginx/nginx.pid
chown -R $PUID:$PGID /etc/nginx
echo "Changing owner to $PUID:$PGID, done."
fi
if [ "${PUID}" != "0" ]; then
su-exec $PUID:$PGID "$@"
else
exec "$@"
fi

View File

@@ -1,7 +0,0 @@
// This script generates a random secure key with a length of 64 characters
// This key is used to encrypt and decrypt the integration secrets for auth.js
// In production it is generated in run.sh and stored in the environment variables ENCRYPTION_KEY / AUTH_SECRET
// during runtime, it's also stored in a file.
const crypto = require("crypto");
console.log(crypto.randomBytes(32).toString("hex"));

View File

@@ -1,3 +1,7 @@
# Create sub directories in volume
mkdir -p /appdata/db
mkdir -p /appdata/redis
# Run migrations
if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
echo "DB migrations are disabled, skipping"
@@ -6,31 +10,8 @@ else
node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
fi
# Generates an encryption key if it doesn't exist and saves it to /secrets/encryptionKey
# Also sets the ENCRYPTION_KEY environment variable
encryptionKey=""
if [ -r /secrets/encryptionKey ]; then
echo "Encryption key already exists"
encryptionKey=$(cat /secrets/encryptionKey)
else
echo "Generating encryption key"
encryptionKey=$(node ./generateRandomSecureKey.js)
echo $encryptionKey > /secrets/encryptionKey
fi
export ENCRYPTION_KEY=$encryptionKey
# Generates an auth secret if it doesn't exist and saves it to /secrets/authSecret
# Also sets the AUTH_SECRET environment variable required for auth.js
authSecret=""
if [ -r /secrets/authSecret ]; then
echo "Auth secret already exists"
authSecret=$(cat /secrets/authSecret)
else
echo "Generating auth secret"
authSecret=$(node ./generateRandomSecureKey.js)
echo $authSecret > /secrets/authSecret
fi
export AUTH_SECRET=$authSecret
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
export AUTH_SECRET=$(openssl rand -base64 32)
# Start nginx proxy
# 1. Replace the HOSTNAME in the nginx template file

View File

@@ -24,7 +24,7 @@
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"typescript-eslint": "^8.18.2"
"typescript-eslint": "^8.19.0"
},
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",

View File

@@ -23,7 +23,6 @@
"AUTH_OIDC_AUTO_LOGIN",
"AUTH_LOGOUT_REDIRECT_URL",
"AUTH_PROVIDERS",
"AUTH_SECRET",
"AUTH_SESSION_EXPIRY_TIME",
"CI",
"DISABLE_REDIS_LOGS",
@@ -38,6 +37,7 @@
"DOCKER_PORTS",
"NODE_ENV",
"PORT",
"SECRET_ENCRYPTION_KEY",
"SKIP_ENV_VALIDATION"
],
"ui": "stream",