chore(release): automatic release v1.16.0
This commit is contained in:
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -31,6 +31,7 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Homarr are you running?
|
description: What version of Homarr are you running?
|
||||||
options:
|
options:
|
||||||
|
- 1.15.0
|
||||||
- 1.14.0
|
- 1.14.0
|
||||||
- 1.13.1
|
- 1.13.1
|
||||||
- 1.13.0
|
- 1.13.0
|
||||||
|
|||||||
@@ -56,25 +56,25 @@
|
|||||||
"@mantine/tiptap": "^7.17.4",
|
"@mantine/tiptap": "^7.17.4",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@tabler/icons-react": "^3.31.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"@tanstack/react-query": "^5.72.1",
|
"@tanstack/react-query": "^5.72.2",
|
||||||
"@tanstack/react-query-devtools": "^5.72.1",
|
"@tanstack/react-query-devtools": "^5.72.2",
|
||||||
"@tanstack/react-query-next-experimental": "^5.72.1",
|
"@tanstack/react-query-next-experimental": "^5.72.2",
|
||||||
"@trpc/client": "^11.0.4",
|
"@trpc/client": "^11.1.0",
|
||||||
"@trpc/next": "^11.0.4",
|
"@trpc/next": "^11.1.0",
|
||||||
"@trpc/react-query": "^11.0.4",
|
"@trpc/react-query": "^11.1.0",
|
||||||
"@trpc/server": "^11.0.4",
|
"@trpc/server": "^11.1.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"chroma-js": "^3.1.2",
|
"chroma-js": "^3.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.5.0",
|
||||||
"flag-icons": "^7.3.2",
|
"flag-icons": "^7.3.2",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.86.3",
|
"sass": "^1.86.3",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"swagger-ui-react": "^5.20.7",
|
"swagger-ui-react": "^5.20.8",
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"@types/chroma-js": "3.1.1",
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "19.1.0",
|
"@types/react": "19.1.1",
|
||||||
"@types/react-dom": "19.1.2",
|
"@types/react-dom": "19.1.2",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
|||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { AppSelectModal } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { useItemActions } from "~/components/board/items/item-actions";
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
@@ -62,8 +64,10 @@ export const BoardContentHeaderActions = () => {
|
|||||||
const AddMenu = () => {
|
const AddMenu = () => {
|
||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
const { addDynamicSection } = useDynamicSectionActions();
|
const { addDynamicSection } = useDynamicSectionActions();
|
||||||
|
const { createItem } = useItemActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const handleAddCategory = useCallback(
|
const handleAddCategory = useCallback(
|
||||||
@@ -90,6 +94,17 @@ const AddMenu = () => {
|
|||||||
openItemSelectModal();
|
openItemSelectModal();
|
||||||
}, [openItemSelectModal]);
|
}, [openItemSelectModal]);
|
||||||
|
|
||||||
|
const handleSelectApp = useCallback(() => {
|
||||||
|
openAppSelectModal({
|
||||||
|
onSelect: (appId) => {
|
||||||
|
createItem({
|
||||||
|
kind: "app",
|
||||||
|
options: { appId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openAppSelectModal, createItem]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" withArrow>
|
<Menu position="bottom-end" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -101,10 +116,14 @@ const AddMenu = () => {
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||||
{t("item.action.create")}
|
{t("item.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||||
|
{t("app.action.add")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
import { startTransition } from "react";
|
||||||
|
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { IconPhotoOff } from "@tabler/icons-react";
|
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { UploadMedia } from "@homarr/forms-collection";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||||
@@ -62,58 +64,76 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Autocomplete
|
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||||
leftSection={
|
<Autocomplete
|
||||||
form.values.backgroundImageUrl &&
|
flex={1}
|
||||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
leftSection={
|
||||||
<Popover width={300} withArrow>
|
form.values.backgroundImageUrl &&
|
||||||
<Popover.Target>
|
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||||
<Center h="100%">
|
<Popover width={300} withArrow>
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
<Popover.Target>
|
||||||
</Center>
|
<Center h="100%">
|
||||||
</Popover.Target>
|
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||||
<Popover.Dropdown>
|
</Center>
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
</Popover.Target>
|
||||||
</Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
</Popover>
|
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||||
)
|
</Popover.Dropdown>
|
||||||
}
|
</Popover>
|
||||||
// We filter it on the server
|
)
|
||||||
filter={({ options }) => options}
|
}
|
||||||
label={t("board.field.backgroundImageUrl.label")}
|
// We filter it on the server
|
||||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
filter={({ options }) => options}
|
||||||
renderOption={({ option }) => {
|
label={t("board.field.backgroundImageUrl.label")}
|
||||||
const current = imageMap.get(option.value);
|
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||||
if (!current) return null;
|
renderOption={({ option }) => {
|
||||||
|
const current = imageMap.get(option.value);
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ImagePreview src={option.value} w={20} h={20} />
|
<ImagePreview src={option.value} w={20} h={20} />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm">{current.name}</Text>
|
<Text size="sm">{current.name}</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{option.value}
|
{option.value}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
group: t("board.field.backgroundImageUrl.group.your"),
|
group: t("board.field.backgroundImageUrl.group.your"),
|
||||||
items: images
|
items: images
|
||||||
.filter((media) => media.creatorId === session?.user.id)
|
.filter((media) => media.creatorId === session?.user.id)
|
||||||
.map((media) => `/api/user-medias/${media.id}`),
|
.map((media) => `/api/user-medias/${media.id}`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: t("board.field.backgroundImageUrl.group.other"),
|
group: t("board.field.backgroundImageUrl.group.other"),
|
||||||
items: images
|
items: images
|
||||||
.filter((media) => media.creatorId !== session?.user.id)
|
.filter((media) => media.creatorId !== session?.user.id)
|
||||||
.map((media) => `/api/user-medias/${media.id}`),
|
.map((media) => `/api/user-medias/${media.id}`),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
{...form.getInputProps("backgroundImageUrl")}
|
{...form.getInputProps("backgroundImageUrl")}
|
||||||
/>
|
/>
|
||||||
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
|
<UploadMedia
|
||||||
|
onSuccess={({ url }) =>
|
||||||
|
startTransition(() => {
|
||||||
|
form.setFieldValue("backgroundImageUrl", url);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ onClick, loading }) => (
|
||||||
|
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||||
|
<IconUpload size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</UploadMedia>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<SelectWithDescriptionBadge
|
<SelectWithDescriptionBadge
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { getSectionElements } from "./section-elements";
|
|||||||
|
|
||||||
export interface CreateItemInput {
|
export interface CreateItemInput {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createItemCallback =
|
export const createItemCallback =
|
||||||
({ kind }: CreateItemInput) =>
|
({ kind, options = {} }: CreateItemInput) =>
|
||||||
(previous: Board): Board => {
|
(previous: Board): Board => {
|
||||||
const firstSection = previous.sections
|
const firstSection = previous.sections
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
@@ -24,7 +25,7 @@ export const createItemCallback =
|
|||||||
const widget = {
|
const widget = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind,
|
kind,
|
||||||
options: {},
|
options,
|
||||||
layouts: createItemLayouts(previous, firstSection),
|
layouts: createItemLayouts(previous, firstSection),
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
|
|||||||
@@ -36,9 +36,9 @@
|
|||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.5.0",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"undici": "7.7.0"
|
"undici": "7.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.5.0",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"ws": "^8.18.1"
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"semantic-release": "^24.2.3",
|
"semantic-release": "^24.2.3",
|
||||||
"testcontainers": "^10.24.1",
|
"testcontainers": "^10.24.2",
|
||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"tree-sitter-json"
|
"tree-sitter-json"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"proxmox-api>undici": "7.7.0"
|
"proxmox-api>undici": "7.8.0"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||||
|
|||||||
@@ -41,11 +41,11 @@
|
|||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@kubernetes/client-node": "^1.1.2",
|
"@kubernetes/client-node": "^1.1.2",
|
||||||
"@trpc/client": "^11.0.4",
|
"@trpc/client": "^11.1.0",
|
||||||
"@trpc/react-query": "^11.0.4",
|
"@trpc/react-query": "^11.1.0",
|
||||||
"@trpc/server": "^11.0.4",
|
"@trpc/server": "^11.1.0",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"pretty-print-error": "^1.1.2",
|
"pretty-print-error": "^1.1.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "7.4.0",
|
"ldapts": "7.4.0",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"pretty-print-error": "^1.1.2",
|
"pretty-print-error": "^1.1.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"undici": "7.7.0"
|
"undici": "7.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.7"
|
"dotenv": "^16.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"undici": "7.7.0",
|
"undici": "7.8.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -46,9 +46,9 @@
|
|||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.17.4",
|
"@mantine/core": "^7.17.4",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@testcontainers/mysql": "^10.24.1",
|
"@testcontainers/mysql": "^10.24.2",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-kit": "^0.30.6",
|
"drizzle-kit": "^0.30.6",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export const integrationDefs = {
|
|||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
|
||||||
category: ["downloadClient", "torrent"],
|
category: ["downloadClient", "torrent"],
|
||||||
},
|
},
|
||||||
|
aria2: {
|
||||||
|
name: "Aria2",
|
||||||
|
secretKinds: [[], ["apiKey"]],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
|
||||||
|
category: ["downloadClient", "torrent", "miscellaneous"],
|
||||||
|
},
|
||||||
sonarr: {
|
sonarr: {
|
||||||
name: "Sonarr",
|
name: "Sonarr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
@@ -211,6 +217,7 @@ export type IntegrationCategory =
|
|||||||
| "downloadClient"
|
| "downloadClient"
|
||||||
| "usenet"
|
| "usenet"
|
||||||
| "torrent"
|
| "torrent"
|
||||||
|
| "miscellaneous"
|
||||||
| "smartHomeServer"
|
| "smartHomeServer"
|
||||||
| "indexerManager"
|
| "indexerManager"
|
||||||
| "healthMonitoring"
|
| "healthMonitoring"
|
||||||
|
|||||||
@@ -36,10 +36,11 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
|
"maria2": "^0.4.0",
|
||||||
"node-ical": "^0.20.1",
|
"node-ical": "^0.20.1",
|
||||||
"proxmox-api": "1.1.1",
|
"proxmox-api": "1.1.1",
|
||||||
"tsdav": "^2.1.3",
|
"tsdav": "^2.1.3",
|
||||||
"undici": "7.7.0",
|
"undici": "7.8.0",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
|
|||||||
|
|
||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||||
|
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
|
||||||
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
|
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
|
||||||
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
|
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
|
||||||
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
|
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
|
||||||
@@ -78,6 +79,7 @@ export const integrationCreators = {
|
|||||||
qBittorrent: QBitTorrentIntegration,
|
qBittorrent: QBitTorrentIntegration,
|
||||||
deluge: DelugeIntegration,
|
deluge: DelugeIntegration,
|
||||||
transmission: TransmissionIntegration,
|
transmission: TransmissionIntegration,
|
||||||
|
aria2: Aria2Integration,
|
||||||
jellyseerr: JellyseerrIntegration,
|
jellyseerr: JellyseerrIntegration,
|
||||||
overseerr: OverseerrIntegration,
|
overseerr: OverseerrIntegration,
|
||||||
prowlarr: ProwlarrIntegration,
|
prowlarr: ProwlarrIntegration,
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
|
||||||
|
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||||
|
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||||
|
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
|
||||||
|
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
|
||||||
|
|
||||||
|
export class Aria2Integration extends DownloadClientIntegration {
|
||||||
|
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||||
|
const client = this.getClient();
|
||||||
|
const keys: (keyof Aria2Download)[] = [
|
||||||
|
"bittorrent",
|
||||||
|
"uploadLength",
|
||||||
|
"uploadSpeed",
|
||||||
|
"downloadSpeed",
|
||||||
|
"totalLength",
|
||||||
|
"completedLength",
|
||||||
|
"files",
|
||||||
|
"status",
|
||||||
|
"gid",
|
||||||
|
];
|
||||||
|
const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([
|
||||||
|
client.tellActive(),
|
||||||
|
client.tellWaiting(0, 1000, keys),
|
||||||
|
client.tellStopped(0, 1000, keys),
|
||||||
|
client.getGlobalStat(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads];
|
||||||
|
const allPaused = downloads.every((download) => download.status === "paused");
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
types: ["torrent", "miscellaneous"],
|
||||||
|
paused: allPaused,
|
||||||
|
rates: {
|
||||||
|
up: Number(globalStats.uploadSpeed),
|
||||||
|
down: Number(globalStats.downloadSpeed),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: downloads.map((download, index) => {
|
||||||
|
const totalSize = Number(download.totalLength);
|
||||||
|
const completedSize = Number(download.completedLength);
|
||||||
|
const progress = totalSize > 0 ? completedSize / totalSize : 0;
|
||||||
|
|
||||||
|
const itemName = download.bittorrent?.info?.name ?? path.basename(download.files[0]?.path ?? "Unknown");
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
id: download.gid,
|
||||||
|
name: itemName,
|
||||||
|
type: download.bittorrent ? "torrent" : "miscellaneous",
|
||||||
|
size: totalSize,
|
||||||
|
sent: Number(download.uploadLength),
|
||||||
|
downSpeed: Number(download.downloadSpeed),
|
||||||
|
upSpeed: Number(download.uploadSpeed),
|
||||||
|
time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)),
|
||||||
|
state: this.getState(download.status, Boolean(download.bittorrent)),
|
||||||
|
category: [],
|
||||||
|
progress,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
} as DownloadClientJobsAndStatus;
|
||||||
|
}
|
||||||
|
public async pauseQueueAsync(): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
await client.pauseAll();
|
||||||
|
}
|
||||||
|
public async pauseItemAsync(item: DownloadClientItem): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
await client.pause(item.id);
|
||||||
|
}
|
||||||
|
public async resumeQueueAsync(): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
await client.unpauseAll();
|
||||||
|
}
|
||||||
|
public async resumeItemAsync(item: DownloadClientItem): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
await client.unpause(item.id);
|
||||||
|
}
|
||||||
|
public async deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
// Note: Remove download file is not support by aria2, replace with forceremove
|
||||||
|
|
||||||
|
if (item.state in ["downloading", "leeching", "paused"]) {
|
||||||
|
await (fromDisk ? client.remove(item.id) : client.forceRemove(item.id));
|
||||||
|
} else {
|
||||||
|
await client.removeDownloadResult(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testConnectionAsync(): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
await client.getVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClient() {
|
||||||
|
const url = this.url("/jsonrpc");
|
||||||
|
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (target, method: keyof Aria2GetClient) => {
|
||||||
|
return async (...args: Parameters<Aria2GetClient[typeof method]>) => {
|
||||||
|
let params = [...args];
|
||||||
|
if (this.hasSecretValue("apiKey")) {
|
||||||
|
params = [`token:${this.getSecretValue("apiKey")}`, ...params];
|
||||||
|
}
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
|
||||||
|
method: `aria2.${method}`,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody = (await response.json()) as { result: ReturnType<Aria2GetClient[typeof method]> };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
return responseBody.result;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
} else {
|
||||||
|
throw new Error("Error communicating with Aria2");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) as Aria2GetClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] {
|
||||||
|
return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status);
|
||||||
|
}
|
||||||
|
private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||||
|
switch (aria2Status) {
|
||||||
|
case "active":
|
||||||
|
return "downloading";
|
||||||
|
case "waiting":
|
||||||
|
return "queued";
|
||||||
|
case "paused":
|
||||||
|
return "paused";
|
||||||
|
case "complete":
|
||||||
|
return "completed";
|
||||||
|
case "error":
|
||||||
|
return "failed";
|
||||||
|
case "removed":
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||||
|
switch (aria2Status) {
|
||||||
|
case "active":
|
||||||
|
return "leeching";
|
||||||
|
case "waiting":
|
||||||
|
return "queued";
|
||||||
|
case "paused":
|
||||||
|
return "paused";
|
||||||
|
case "complete":
|
||||||
|
return "completed";
|
||||||
|
case "error":
|
||||||
|
return "failed";
|
||||||
|
case "removed":
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private calculateEta(completed: number, total: number, speed: number): number {
|
||||||
|
if (speed === 0 || completed >= total) return 0;
|
||||||
|
return Math.floor((total - completed) / speed) * 1000; // Convert to milliseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export interface Aria2GetClient {
|
||||||
|
getVersion: Aria2VoidFunc<Aria2GetVersion>;
|
||||||
|
getGlobalStat: Aria2VoidFunc<Aria2GetGlobalStat>;
|
||||||
|
|
||||||
|
tellActive: Aria2VoidFunc<Aria2Download[]>;
|
||||||
|
tellWaiting: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||||
|
tellStopped: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||||
|
tellStatus: Aria2GidFunc<Aria2Download, Aria2TellStatusListParams>;
|
||||||
|
|
||||||
|
pause: Aria2GidFunc<AriaGID>;
|
||||||
|
pauseAll: Aria2VoidFunc<"OK">;
|
||||||
|
unpause: Aria2GidFunc<AriaGID>;
|
||||||
|
unpauseAll: Aria2VoidFunc<"OK">;
|
||||||
|
remove: Aria2GidFunc<AriaGID>;
|
||||||
|
forceRemove: Aria2GidFunc<AriaGID>;
|
||||||
|
removeDownloadResult: Aria2GidFunc<"OK">;
|
||||||
|
}
|
||||||
|
type AriaGID = string;
|
||||||
|
|
||||||
|
type Aria2GidFunc<R = void, T extends unknown[] = []> = (gid: string, ...args: T) => Promise<R>;
|
||||||
|
type Aria2VoidFunc<R = void, T extends unknown[] = []> = (...args: T) => Promise<R>;
|
||||||
|
|
||||||
|
type Aria2TellStatusListParams = [offset: number, num: number, keys?: [keyof Aria2Download] | (keyof Aria2Download)[]];
|
||||||
|
|
||||||
|
export interface Aria2GetVersion {
|
||||||
|
enabledFeatures: string[];
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
export interface Aria2GetGlobalStat {
|
||||||
|
downloadSpeed: string;
|
||||||
|
uploadSpeed: string;
|
||||||
|
numActive: string;
|
||||||
|
numWaiting: string;
|
||||||
|
numStopped: string;
|
||||||
|
numStoppedTotal: string;
|
||||||
|
}
|
||||||
|
export interface Aria2Download {
|
||||||
|
gid: AriaGID;
|
||||||
|
status: "active" | "waiting" | "paused" | "error" | "complete" | "removed";
|
||||||
|
totalLength: string;
|
||||||
|
completedLength: string;
|
||||||
|
uploadLength: string;
|
||||||
|
bitfield: string;
|
||||||
|
downloadSpeed: string;
|
||||||
|
uploadSpeed: string;
|
||||||
|
infoHash?: string;
|
||||||
|
numSeeders?: string;
|
||||||
|
seeder?: "true" | "false";
|
||||||
|
pieceLength: string;
|
||||||
|
numPieces: string;
|
||||||
|
connections: string;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
followedBy?: AriaGID[];
|
||||||
|
following?: AriaGID;
|
||||||
|
belongsTo?: AriaGID;
|
||||||
|
dir: string;
|
||||||
|
files: {
|
||||||
|
index: number;
|
||||||
|
path: string;
|
||||||
|
length: string;
|
||||||
|
completedLength: string;
|
||||||
|
selected: "true" | "false";
|
||||||
|
uris: {
|
||||||
|
status: "waiting" | "used";
|
||||||
|
uri: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
bittorrent?: {
|
||||||
|
announceList: string[];
|
||||||
|
comment?: string | { "utf-8": string };
|
||||||
|
creationDate?: number;
|
||||||
|
mode?: "single" | "multi";
|
||||||
|
info?: {
|
||||||
|
name: string | { "utf-8": string };
|
||||||
|
};
|
||||||
|
verifiedLength?: number;
|
||||||
|
verifyIntegrityPending?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
|
|||||||
down: Math.floor(download_rate),
|
down: Math.floor(download_rate),
|
||||||
up: Math.floor(upload_rate),
|
up: Math.floor(upload_rate),
|
||||||
},
|
},
|
||||||
type,
|
types: [type],
|
||||||
};
|
};
|
||||||
const items = torrents.map((torrent): DownloadClientItem => {
|
const items = torrents.map((torrent): DownloadClientItem => {
|
||||||
const state = DelugeIntegration.getTorrentState(torrent.state);
|
const state = DelugeIntegration.getTorrentState(torrent.state);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
|
|||||||
const status: DownloadClientStatus = {
|
const status: DownloadClientStatus = {
|
||||||
paused: nzbGetStatus.DownloadPaused,
|
paused: nzbGetStatus.DownloadPaused,
|
||||||
rates: { down: nzbGetStatus.DownloadRate },
|
rates: { down: nzbGetStatus.DownloadRate },
|
||||||
type,
|
types: [type],
|
||||||
};
|
};
|
||||||
const items = queue
|
const items = queue
|
||||||
.map((file): DownloadClientItem => {
|
.map((file): DownloadClientItem => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
|
|||||||
);
|
);
|
||||||
const paused =
|
const paused =
|
||||||
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
|
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
|
||||||
const status: DownloadClientStatus = { paused, rates, type };
|
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||||
const items = torrents.map((torrent): DownloadClientItem => {
|
const items = torrents.map((torrent): DownloadClientItem => {
|
||||||
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
|
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
|
|||||||
const status: DownloadClientStatus = {
|
const status: DownloadClientStatus = {
|
||||||
paused: queue.paused,
|
paused: queue.paused,
|
||||||
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
|
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
|
||||||
type,
|
types: [type],
|
||||||
};
|
};
|
||||||
const items = queue.slots
|
const items = queue.slots
|
||||||
.map((slot): DownloadClientItem => {
|
.map((slot): DownloadClientItem => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class TransmissionIntegration extends DownloadClientIntegration {
|
|||||||
);
|
);
|
||||||
const paused =
|
const paused =
|
||||||
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
|
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
|
||||||
const status: DownloadClientStatus = { paused, rates, type };
|
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||||
const items = torrents.map((torrent): DownloadClientItem => {
|
const items = torrents.map((torrent): DownloadClientItem => {
|
||||||
const state = TransmissionIntegration.getTorrentState(torrent.status);
|
const state = TransmissionIntegration.getTorrentState(torrent.status);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
|||||||
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
||||||
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
||||||
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
||||||
|
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
||||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export const downloadClientItemSchema = z.object({
|
|||||||
index: z.number(),
|
index: z.number(),
|
||||||
/** Filename */
|
/** Filename */
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
/** Torrent/Usenet identifier */
|
/** Download Client identifier */
|
||||||
type: z.enum(["torrent", "usenet"]),
|
type: z.enum(["torrent", "usenet", "miscellaneous"]),
|
||||||
/** Item size in Bytes */
|
/** Item size in Bytes */
|
||||||
size: z.number(),
|
size: z.number(),
|
||||||
/** Total uploaded in Bytes, only required for Torrent items */
|
/** Total uploaded in Bytes, only required for Torrent items */
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface DownloadClientStatus {
|
|||||||
down: number;
|
down: number;
|
||||||
up?: number;
|
up?: number;
|
||||||
};
|
};
|
||||||
type: "usenet" | "torrent";
|
types: ("usenet" | "torrent" | "miscellaneous")[];
|
||||||
}
|
}
|
||||||
export interface ExtendedClientStatus {
|
export interface ExtendedClientStatus {
|
||||||
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
||||||
|
|||||||
153
packages/integrations/test/aria2.spec.ts
Normal file
153
packages/integrations/test/aria2.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type { StartedTestContainer } from "testcontainers";
|
||||||
|
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||||
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { Aria2Integration } from "../src";
|
||||||
|
|
||||||
|
const API_KEY = "ARIA2_API_KEY";
|
||||||
|
const IMAGE_NAME = "hurlenko/aria2-ariang:latest";
|
||||||
|
|
||||||
|
describe("Aria2 integration", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const containerRuntimeClient = await getContainerRuntimeClient();
|
||||||
|
await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
|
||||||
|
}, 100_000);
|
||||||
|
|
||||||
|
test("Test connection should work", async () => {
|
||||||
|
// Arrange
|
||||||
|
const startedContainer = await createAria2Container().start();
|
||||||
|
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await aria2Integration.testConnectionAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await startedContainer.stop();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
test("pauseQueueAsync should work", async () => {
|
||||||
|
// Arrange
|
||||||
|
const startedContainer = await createAria2Container().start();
|
||||||
|
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||||
|
|
||||||
|
// Acts
|
||||||
|
const actAsync = async () => await aria2Integration.pauseQueueAsync();
|
||||||
|
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).resolves.not.toThrow();
|
||||||
|
await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } });
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await startedContainer.stop();
|
||||||
|
}, 30_000); // Timeout of 30 seconds
|
||||||
|
|
||||||
|
test("Items should be empty", async () => {
|
||||||
|
// Arrange
|
||||||
|
const startedContainer = await createAria2Container().start();
|
||||||
|
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(getAsync()).resolves.not.toThrow();
|
||||||
|
await expect(getAsync()).resolves.toMatchObject({
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await startedContainer.stop();
|
||||||
|
}, 30_000); // Timeout of 30 seconds
|
||||||
|
|
||||||
|
test("1 Items should exist after adding one", async () => {
|
||||||
|
// Arrange
|
||||||
|
const startedContainer = await createAria2Container().start();
|
||||||
|
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||||
|
await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(getAsync()).resolves.not.toThrow();
|
||||||
|
expect((await getAsync()).items).toHaveLength(1);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await startedContainer.stop();
|
||||||
|
}, 30_000); // Timeout of 30 seconds
|
||||||
|
|
||||||
|
test("Delete item should result in empty items", async () => {
|
||||||
|
// Arrange
|
||||||
|
const startedContainer = await createAria2Container().start();
|
||||||
|
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||||
|
const item = await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await aria2Integration.deleteItemAsync(item, true);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).resolves.not.toThrow();
|
||||||
|
// NzbGet is slow and we wait for a second before querying the items. Test was flaky without this.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
const result = await aria2Integration.getClientJobsAndStatusAsync();
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await startedContainer.stop();
|
||||||
|
}, 30_000); // Timeout of 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAria2Container = () => {
|
||||||
|
return new GenericContainer(IMAGE_NAME)
|
||||||
|
.withExposedPorts(8080)
|
||||||
|
.withEnvironment({
|
||||||
|
PUID: "1000",
|
||||||
|
PGID: "1000",
|
||||||
|
ARIA2RPCPORT: "443",
|
||||||
|
RPC_SECRET: API_KEY,
|
||||||
|
})
|
||||||
|
.withWaitStrategy(Wait.forLogMessage("listening on TCP port"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAria2Intergration = (container: StartedTestContainer, apikey: string) => {
|
||||||
|
return new Aria2Integration({
|
||||||
|
id: "1",
|
||||||
|
decryptedSecrets: [
|
||||||
|
{
|
||||||
|
kind: "apiKey",
|
||||||
|
value: apikey,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: "Aria2",
|
||||||
|
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const aria2AddItemAsync = async (container: StartedTestContainer, apiKey: string, integration: Aria2Integration) => {
|
||||||
|
await fetch(`http://${container.getHost()}:${container.getMappedPort(8080)}/jsonrpc`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
|
||||||
|
method: "aria2.addUri",
|
||||||
|
params: [`token:${apiKey}`, ["https://google.com"]],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(3000);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: [item],
|
||||||
|
} = await integration.getClientJobsAndStatusAsync();
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error("No item found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
const delay = (microseconds: number) => new Promise((resolve) => setTimeout(resolve, microseconds));
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
"ioredis": "5.6.0",
|
"ioredis": "5.6.1",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@mantine/core": "^7.17.4",
|
"@mantine/core": "^7.17.4",
|
||||||
"@tabler/icons-react": "^3.31.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
121
packages/modals-collection/src/apps/app-select-modal.tsx
Normal file
121
packages/modals-collection/src/apps/app-select-modal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||||
|
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||||
|
|
||||||
|
interface AppSelectModalProps {
|
||||||
|
onSelect?: (appId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const t = useI18n();
|
||||||
|
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
|
||||||
|
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
|
||||||
|
|
||||||
|
const filteredApps = useMemo(
|
||||||
|
() =>
|
||||||
|
apps
|
||||||
|
.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[apps, search],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (appId: string) => {
|
||||||
|
if (innerProps.onSelect) {
|
||||||
|
innerProps.onSelect(appId);
|
||||||
|
}
|
||||||
|
actions.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNewApp = () => {
|
||||||
|
openQuickAddAppModal({
|
||||||
|
onClose(createdAppId) {
|
||||||
|
if (innerProps.onSelect) {
|
||||||
|
innerProps.onSelect(createdAppId);
|
||||||
|
}
|
||||||
|
actions.closeModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
leftSection={<IconSearch />}
|
||||||
|
placeholder={`${t("app.action.select.search")}...`}
|
||||||
|
data-autofocus
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||||
|
handleSelect(filteredApps[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||||
|
<Card h="100%">
|
||||||
|
<Stack justify="space-between" h="100%">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Center>
|
||||||
|
<IconPlus size={24} />
|
||||||
|
</Center>
|
||||||
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||||
|
{t("app.action.create.title")}
|
||||||
|
</Text>
|
||||||
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||||
|
{t("app.action.create.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||||
|
{t("app.action.create.action")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{filteredApps.map((app) => (
|
||||||
|
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
||||||
|
<Card h="100%">
|
||||||
|
<Stack justify="space-between" h="100%">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Center>
|
||||||
|
<Image src={app.iconUrl || ""} alt={app.name} width={24} height={24} />
|
||||||
|
</Center>
|
||||||
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||||
|
{app.name}
|
||||||
|
</Text>
|
||||||
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||||
|
{app.description ?? ""}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button onClick={() => handleSelect(app.id)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||||
|
{t("app.action.select.action", { app: app.name })}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredApps.length === 0 && !isPending && (
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<Center p="xl">
|
||||||
|
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||||
|
</Center>
|
||||||
|
</Grid.Col>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle: (t) => t("app.action.select.title"),
|
||||||
|
size: "xl",
|
||||||
|
});
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export { AppSelectModal } from "./app-select-modal";
|
||||||
export { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
export { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
import { AppForm } from "@homarr/forms-collection";
|
import { AppForm } from "@homarr/forms-collection";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -8,7 +9,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|||||||
import type { appManageSchema } from "@homarr/validation/app";
|
import type { appManageSchema } from "@homarr/validation/app";
|
||||||
|
|
||||||
interface QuickAddAppModalProps {
|
interface QuickAddAppModalProps {
|
||||||
onClose: (createdAppId: string) => Promise<void>;
|
onClose: (createdAppId: string) => MaybePromise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"@mantine/core": "^7.17.4",
|
"@mantine/core": "^7.17.4",
|
||||||
"@mantine/hooks": "^7.17.4",
|
"@mantine/hooks": "^7.17.4",
|
||||||
"adm-zip": "0.5.16",
|
"adm-zip": "0.5.16",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const optionMapping: OptionMapping = {
|
|||||||
defaultSort: () => "type",
|
defaultSort: () => "type",
|
||||||
descendingDefaultSort: () => false,
|
descendingDefaultSort: () => false,
|
||||||
showCompletedUsenet: () => true,
|
showCompletedUsenet: () => true,
|
||||||
|
showCompletedHttp: () => true,
|
||||||
},
|
},
|
||||||
weather: {
|
weather: {
|
||||||
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@homarr/db": "workspace:^",
|
"@homarr/db": "workspace:^",
|
||||||
"@homarr/definitions": "workspace:^",
|
"@homarr/definitions": "workspace:^",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"ioredis": "5.6.0",
|
"ioredis": "5.6.1",
|
||||||
"superjson": "2.2.2"
|
"superjson": "2.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"octokit": "^4.1.2",
|
"octokit": "^4.1.3",
|
||||||
"pretty-print-error": "^1.1.2",
|
"pretty-print-error": "^1.1.2",
|
||||||
"superjson": "2.2.2"
|
"superjson": "2.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@mantine/dates": "^7.17.4",
|
"@mantine/dates": "^7.17.4",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@mantine/spotlight": "^7.17.4",
|
"@mantine/spotlight": "^7.17.4",
|
||||||
"@tabler/icons-react": "^3.31.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"deepmerge": "4.3.1",
|
"deepmerge": "4.3.1",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"next-intl": "4.0.2",
|
"next-intl": "4.0.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "显示标记为已完成的 Torrent 条目"
|
"label": "显示标记为已完成的 Torrent 条目"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "隐藏在此阈值下已完成的 Torrent (kiB/s)"
|
"label": "隐藏在此阈值下已完成的 Torrent (kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Vis torrent poster markeret som fuldførte"
|
"label": "Vis torrent poster markeret som fuldførte"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Skjul afsluttet torrent under denne tærskel (i kiB/s)"
|
"label": "Skjul afsluttet torrent under denne tærskel (i kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Torrent Einträge anzeigen die abgeschlossen wurden"
|
"label": "Torrent Einträge anzeigen die abgeschlossen wurden"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Abgeschlossene Torrents unter diesem Schwellenwert ausblenden (in kiB/s)"
|
"label": "Abgeschlossene Torrents unter diesem Schwellenwert ausblenden (in kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Torrent Einträge anzeigen die abgeschlossen wurden"
|
"label": "Torrent Einträge anzeigen die abgeschlossen wurden"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Abgeschlossene Torrents unter diesem Schwellenwert ausblenden (in kiB/s)"
|
"label": "Abgeschlossene Torrents unter diesem Schwellenwert ausblenden (in kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -611,8 +611,18 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"select": {
|
"select": {
|
||||||
"label": "Select app",
|
"label": "Select app",
|
||||||
"notFound": "No app found"
|
"notFound": "No app found",
|
||||||
}
|
"search": "Search for an app",
|
||||||
|
"noResults": "No results",
|
||||||
|
"action": "Select {app}",
|
||||||
|
"title": "Select an app to add to this board"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"title": "Create new app",
|
||||||
|
"description": "Create a new app ",
|
||||||
|
"action": "Open app creation"
|
||||||
|
},
|
||||||
|
"add": "Add an app"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"integration": {
|
"integration": {
|
||||||
@@ -1786,6 +1796,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Show torrent entries marked as completed"
|
"label": "Show torrent entries marked as completed"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": "Show Miscellaneous entries marked as completed"
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Hide completed torrent under this threshold (in kiB/s)"
|
"label": "Hide completed torrent under this threshold (in kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Afficher les entrées Torrent marquées comme terminées"
|
"label": "Afficher les entrées Torrent marquées comme terminées"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Masquer les Torrents terminés sous ce seuil (en kiB/s)"
|
"label": "Masquer les Torrents terminés sous ce seuil (en kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "הצג ערכי טורנט שסומנו כהשלמה"
|
"label": "הצג ערכי טורנט שסומנו כהשלמה"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "הסתר טורנט שהושלם מתחת לסף הזה (בקיB/s)"
|
"label": "הסתר טורנט שהושלם מתחת לסף הזה (בקיB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Torrentvermeldingen weergeven die zijn gemarkeerd als voltooid"
|
"label": "Torrentvermeldingen weergeven die zijn gemarkeerd als voltooid"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Voltooide torrent verbergen onder deze drempel (in kiB/s)"
|
"label": "Voltooide torrent verbergen onder deze drempel (in kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Vis torrentoppføringer markert som fullført"
|
"label": "Vis torrentoppføringer markert som fullført"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Skjul fullført torrent under denne terskelen (i kiB/s)"
|
"label": "Skjul fullført torrent under denne terskelen (i kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Pokaż wpisy torrent oznaczone jako ukończone"
|
"label": "Pokaż wpisy torrent oznaczone jako ukończone"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Ukryj ukończony torrent poniżej tego progu (w kiB/s)"
|
"label": "Ukryj ukończony torrent poniżej tego progu (w kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Показывать завершённые торренты"
|
"label": "Показывать завершённые торренты"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Скрывать завершённые торренты ниже этого порога (в КиБ/с)"
|
"label": "Скрывать завершённые торренты ниже этого порога (в КиБ/с)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Zobraziť položky torrentu označené ako dokončené"
|
"label": "Zobraziť položky torrentu označené ako dokončené"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Skryť dokončený torrent pod touto hranicou (v kiB/s)"
|
"label": "Skryť dokončený torrent pod touto hranicou (v kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "Tamamlanmış olarak işaretlenen torrent girdilerini göster"
|
"label": "Tamamlanmış olarak işaretlenen torrent girdilerini göster"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Tamamlanan torrent'i bu eşik değerinin altına gizle (kiB/s cinsinden)"
|
"label": "Tamamlanan torrent'i bu eşik değerinin altına gizle (kiB/s cinsinden)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "Приховувати завершені торенти нижче за цей поріг (у КіБ/с)"
|
"label": "Приховувати завершені торенти нижче за цей поріг (у КіБ/с)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@
|
|||||||
"showCompletedTorrent": {
|
"showCompletedTorrent": {
|
||||||
"label": "顯示標記為已完成的 Torrent 項目"
|
"label": "顯示標記為已完成的 Torrent 項目"
|
||||||
},
|
},
|
||||||
|
"showCompletedHttp": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"activeTorrentThreshold": {
|
"activeTorrentThreshold": {
|
||||||
"label": "隱藏在此閥值下完成的 Torrent (kiB/s)"
|
"label": "隱藏在此閥值下完成的 Torrent (kiB/s)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"@mantine/hooks": "^7.17.4",
|
"@mantine/hooks": "^7.17.4",
|
||||||
"@tabler/icons-react": "^3.31.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.2.5",
|
"next": "15.3.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ export default function DownloadClientsWidget({
|
|||||||
options.showCompletedTorrent &&
|
options.showCompletedTorrent &&
|
||||||
(upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
|
(upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
|
||||||
progress !== 1)) ||
|
progress !== 1)) ||
|
||||||
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)),
|
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) ||
|
||||||
|
(type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)),
|
||||||
)
|
)
|
||||||
//Filter following user quick setting
|
//Filter following user quick setting
|
||||||
.filter(
|
.filter(
|
||||||
@@ -189,7 +190,7 @@ export default function DownloadClientsWidget({
|
|||||||
...item,
|
...item,
|
||||||
category: item.category !== undefined && item.category.length > 0 ? item.category : undefined,
|
category: item.category !== undefined && item.category.length > 0 ? item.category : undefined,
|
||||||
received,
|
received,
|
||||||
ratio: item.sent !== undefined ? item.sent / received : undefined,
|
ratio: item.sent !== undefined ? item.sent / (received || 1) : undefined,
|
||||||
//Only add if permission to use mutations
|
//Only add if permission to use mutations
|
||||||
actions: integrationsWithInteractions.includes(pair.integration.id)
|
actions: integrationsWithInteractions.includes(pair.integration.id)
|
||||||
? {
|
? {
|
||||||
@@ -215,6 +216,7 @@ export default function DownloadClientsWidget({
|
|||||||
options.filterIsWhitelist,
|
options.filterIsWhitelist,
|
||||||
options.showCompletedTorrent,
|
options.showCompletedTorrent,
|
||||||
options.showCompletedUsenet,
|
options.showCompletedUsenet,
|
||||||
|
options.showCompletedHttp,
|
||||||
quickFilters,
|
quickFilters,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -232,7 +234,7 @@ export default function DownloadClientsWidget({
|
|||||||
.filter(
|
.filter(
|
||||||
({ category }) =>
|
({ category }) =>
|
||||||
!options.applyFilterToRatio ||
|
!options.applyFilterToRatio ||
|
||||||
data.status.type !== "torrent" ||
|
!data.status.types.includes("torrent") ||
|
||||||
options.filterIsWhitelist ===
|
options.filterIsWhitelist ===
|
||||||
options.categoryFilter.some((filter) =>
|
options.categoryFilter.some((filter) =>
|
||||||
(Array.isArray(category) ? category : [category]).includes(filter),
|
(Array.isArray(category) ? category : [category]).includes(filter),
|
||||||
@@ -258,7 +260,7 @@ export default function DownloadClientsWidget({
|
|||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
({ status: statusA }, { status: statusB }) =>
|
({ status: statusA }, { status: statusB }) =>
|
||||||
(statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity),
|
(statusA?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity),
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
currentItems,
|
currentItems,
|
||||||
@@ -272,8 +274,10 @@ export default function DownloadClientsWidget({
|
|||||||
|
|
||||||
//Check existing types between torrents and usenet
|
//Check existing types between torrents and usenet
|
||||||
const integrationTypes: ExtendedDownloadClientItem["type"][] = [];
|
const integrationTypes: ExtendedDownloadClientItem["type"][] = [];
|
||||||
|
|
||||||
if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent");
|
if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent");
|
||||||
if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet");
|
if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet");
|
||||||
|
if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous");
|
||||||
|
|
||||||
//Set the visibility of columns depending on widget settings and available data/integrations.
|
//Set the visibility of columns depending on widget settings and available data/integrations.
|
||||||
const columnVisibility: MRT_VisibilityState = {
|
const columnVisibility: MRT_VisibilityState = {
|
||||||
@@ -677,15 +681,22 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
|
|||||||
<NormalizedLine itemKey="index" values={item.index} />
|
<NormalizedLine itemKey="index" values={item.index} />
|
||||||
<NormalizedLine itemKey="type" values={item.type} />
|
<NormalizedLine itemKey="type" values={item.type} />
|
||||||
<NormalizedLine itemKey="state" values={t(item.state)} />
|
<NormalizedLine itemKey="state" values={t(item.state)} />
|
||||||
<NormalizedLine
|
{item.type !== "miscellaneous" && (
|
||||||
itemKey="upSpeed"
|
<NormalizedLine
|
||||||
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
|
itemKey="upSpeed"
|
||||||
/>
|
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<NormalizedLine
|
<NormalizedLine
|
||||||
itemKey="downSpeed"
|
itemKey="downSpeed"
|
||||||
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
|
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
|
||||||
/>
|
/>
|
||||||
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
|
|
||||||
|
{item.type !== "miscellaneous" && (
|
||||||
|
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
|
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
|
||||||
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
|
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
|
||||||
<NormalizedLine
|
<NormalizedLine
|
||||||
@@ -696,7 +707,7 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
|
|||||||
unitDisplay: "narrow",
|
unitDisplay: "narrow",
|
||||||
}).format(item.progress)}
|
}).format(item.progress)}
|
||||||
/>
|
/>
|
||||||
<NormalizedLine itemKey="ratio" values={item.ratio} />
|
{item.type !== "miscellaneous" && <NormalizedLine itemKey="ratio" values={item.ratio} />}
|
||||||
<NormalizedLine itemKey="added" values={item.added === undefined ? "unknown" : dayjs(item.added).format()} />
|
<NormalizedLine itemKey="added" values={item.added === undefined ? "unknown" : dayjs(item.added).format()} />
|
||||||
<NormalizedLine itemKey="time" values={item.time !== 0 ? dayjs().add(item.time).format() : "∞"} />
|
<NormalizedLine itemKey="time" values={item.time !== 0 ? dayjs().add(item.time).format() : "∞"} />
|
||||||
<NormalizedLine itemKey="category" values={item.category} />
|
<NormalizedLine itemKey="category" values={item.category} />
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
|||||||
showCompletedTorrent: factory.switch({
|
showCompletedTorrent: factory.switch({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
}),
|
}),
|
||||||
|
showCompletedHttp: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
activeTorrentThreshold: factory.number({
|
activeTorrentThreshold: factory.number({
|
||||||
//in KiB/s
|
//in KiB/s
|
||||||
validate: z.number().min(0),
|
validate: z.number().min(0),
|
||||||
@@ -95,6 +98,10 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
|||||||
shouldHide: (_, integrationKinds) =>
|
shouldHide: (_, integrationKinds) =>
|
||||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||||
},
|
},
|
||||||
|
showCompletedHttp: {
|
||||||
|
shouldHide: (_, integrationKinds) =>
|
||||||
|
!getIntegrationKindsByCategory("miscellaneous").some((kinds) => integrationKinds.includes(kinds)),
|
||||||
|
},
|
||||||
activeTorrentThreshold: {
|
activeTorrentThreshold: {
|
||||||
shouldHide: (_, integrationKinds) =>
|
shouldHide: (_, integrationKinds) =>
|
||||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||||
|
|||||||
1083
pnpm-lock.yaml
generated
1083
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.2.5",
|
"@next/eslint-plugin-next": "15.3.0",
|
||||||
"eslint-config-prettier": "^10.1.1",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-config-turbo": "^2.5.0",
|
"eslint-config-turbo": "^2.5.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user