Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57e45637c | ||
|
|
fe4598f722 | ||
|
|
a755692d81 | ||
|
|
3429b6ff2d | ||
|
|
3ff0d72ba1 | ||
|
|
a63a191203 | ||
|
|
1be906a0cd | ||
|
|
144a55b34e | ||
|
|
46ccc6c245 | ||
|
|
63d8fa8c26 | ||
|
|
b5dd6ae8ef | ||
|
|
d540ad374a | ||
|
|
6a971c1986 | ||
|
|
d86fe3ddf0 | ||
|
|
06db8960a7 | ||
|
|
a5719acf34 | ||
|
|
d1d597c8e1 | ||
|
|
657e42e4bb | ||
|
|
256ebcb864 | ||
|
|
72e86dee01 | ||
|
|
b7910b16e0 | ||
|
|
5a83bb6e39 | ||
|
|
433b66582d | ||
|
|
acbcb6818b | ||
|
|
07d991a0a1 | ||
|
|
92364e9574 |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -18,7 +18,12 @@ export const CustomMantineProvider = ({
|
||||
const manager = useColorSchemeManager();
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={theme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</DirectionProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -33,7 +33,12 @@ export const BoardMantineProvider = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -148,9 +148,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const integrationCategories = integrationDefs[searchParams.kind].category.flat();
|
||||
const supportsSearchEngine =
|
||||
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
|
||||
@@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
enableDensityToggle: false,
|
||||
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
renderTopToolbarCustomActions: () => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
),
|
||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
|
||||
+6
-6
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
@@ -76,14 +76,14 @@
|
||||
"tree-sitter-json"
|
||||
],
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.2",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.2",
|
||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.7",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
|
||||
"hono@<4.6.5": ">=4.12.22",
|
||||
"hono@<4.6.5": ">=4.12.23",
|
||||
"linkifyjs@<4.3.2": ">=4.3.3",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.5",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -16,9 +14,11 @@ import {
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
searchEngines,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIconUrl,
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
integrationCategories,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
integrationSavePermissionsSchema,
|
||||
integrationUpdateSchema,
|
||||
} from "@homarr/validation/integration";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
@@ -42,7 +41,6 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
const logger = createLogger({ module: "integrationRouter" });
|
||||
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -82,7 +80,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -130,7 +128,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -181,7 +179,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -316,7 +314,20 @@ export const integrationRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
|
||||
if (
|
||||
input.attemptSearchEngineCreation &&
|
||||
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
|
||||
) {
|
||||
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(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
@@ -484,7 +495,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
users: userPermissions
|
||||
@@ -492,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
.sort((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||
}),
|
||||
groups: dbGroupIntegrationPermission
|
||||
@@ -503,7 +514,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
permission,
|
||||
}))
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
};
|
||||
@@ -607,149 +618,8 @@ export const integrationRouter = createTRPCRouter({
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
return await integrationInstance.searchAsync(encodeURI(input.query));
|
||||
}),
|
||||
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
|
||||
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
|
||||
|
||||
return integrationsWithAccess.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasInteractAccess: permissions.hasInteractAccess,
|
||||
},
|
||||
};
|
||||
});
|
||||
}),
|
||||
searchMediaRequests: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
integrationIds: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const query = input.query.trim();
|
||||
if (query.length === 0) return [];
|
||||
|
||||
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
|
||||
|
||||
const results = await Promise.all(
|
||||
integrationsWithAccess.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
|
||||
externalUrl: integration.app?.href ?? null,
|
||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
|
||||
searchResults.map((result) => ({
|
||||
...result,
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasInteractAccess: permissions.hasInteractAccess,
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
|
||||
.input(mediaRequestOptionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
});
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
}
|
||||
|
||||
const getAccessibleMediaRequestSearchIntegrationsAsync = async (
|
||||
ctx: IntegrationRouterContext,
|
||||
integrationIds?: string[],
|
||||
) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where:
|
||||
integrationIds && integrationIds.length > 0
|
||||
? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
|
||||
: inArray(integrations.kind, mediaRequestSearchKinds),
|
||||
orderBy: asc(integrations.name),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "One or more media request search integrations were not found",
|
||||
});
|
||||
}
|
||||
|
||||
const integrationsWithAccess = await Promise.all(
|
||||
integrationsFromDb.map(async (integration) => ({
|
||||
integration,
|
||||
hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
|
||||
})),
|
||||
);
|
||||
|
||||
if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query one or more media request search integrations",
|
||||
});
|
||||
}
|
||||
|
||||
return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
|
||||
};
|
||||
|
||||
interface UpdateSecretInput {
|
||||
integrationId: string;
|
||||
value: string;
|
||||
@@ -770,6 +640,35 @@ interface AddSecretInput {
|
||||
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,
|
||||
|
||||
@@ -6,9 +6,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "searchEngineRouter" });
|
||||
@@ -145,6 +148,20 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
|
||||
.input(mediaRequestOptionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-create")
|
||||
.input(searchEngineManageSchema)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
@@ -59,127 +59,6 @@ describe("all should return all integrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
test("with integration use access should only return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin",
|
||||
url: "http://jellyfin.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.kind).toBe("jellyseerr");
|
||||
});
|
||||
|
||||
test("without access should not return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("with direct integration use access should return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMediaRequests should validate scoped integrations", () => {
|
||||
test("with a non media search integration should throw not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin",
|
||||
url: "http://jellyfin.local",
|
||||
});
|
||||
|
||||
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
|
||||
"One or more media request search integrations were not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("without query access to a scoped media search integration should throw forbidden", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
|
||||
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
|
||||
"User does not have permission to query one or more media request search integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an integration by id", () => {
|
||||
test("with full access should return an integration by id", async () => {
|
||||
const db = createDb();
|
||||
@@ -329,7 +208,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
});
|
||||
|
||||
test("with create integration access should not create a search engine for media request search integrations", async () => {
|
||||
test("with create integration access should create a new integration when creating search engine", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
@@ -364,7 +243,12 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
|
||||
expect(dbSearchEngine).toBeUndefined();
|
||||
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/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
);
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration with new linked app", async () => {
|
||||
|
||||
@@ -14,9 +14,7 @@ export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MIN
|
||||
}),
|
||||
);
|
||||
|
||||
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE, {
|
||||
expectedMaximumDurationInMillis: 15_000,
|
||||
}).withCallback(
|
||||
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
|
||||
widgetKinds: ["mediaRequests-requestList"],
|
||||
getInput: {
|
||||
|
||||
@@ -12,14 +12,12 @@ interface SeriesInformation {
|
||||
overview: string;
|
||||
seasons: SerieSeason[];
|
||||
posterPath: string;
|
||||
requestedSeasons: number[];
|
||||
}
|
||||
|
||||
interface MovieInformation {
|
||||
id: number;
|
||||
overview: string;
|
||||
posterPath: string;
|
||||
requestedSeasons: number[];
|
||||
}
|
||||
|
||||
export type MediaInformation = SeriesInformation | MovieInformation;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { RequestInit, Response } from "undici";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import { splitToChunksWithNItems } from "@homarr/common";
|
||||
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
@@ -28,14 +24,6 @@ import {
|
||||
|
||||
const logger = createLogger({ module: "overseerrIntegration" });
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const DETAIL_FETCH_BATCH_SIZE = 5;
|
||||
|
||||
interface OverseerrFetchClient {
|
||||
fetchAsync: (url: URL, init?: RequestInit) => Promise<Response>;
|
||||
fetchUncheckedAsync: (url: URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
interface OverseerrSearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -52,43 +40,12 @@ export class OverseerrIntegration
|
||||
extends Integration
|
||||
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
|
||||
{
|
||||
private async createFetchClientAsync(): Promise<OverseerrFetchClient> {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
const apiKey = this.getSecretValue("apiKey");
|
||||
|
||||
const requestAsync = async (url: URL, init?: RequestInit) => {
|
||||
return await withTimeoutAsync(
|
||||
(signal) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
...init?.headers,
|
||||
},
|
||||
}),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
fetchAsync: async (url, init) => {
|
||||
const response = await requestAsync(url, init);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Overseerr request failed: ${response.status} ${response.statusText} (${url.toString()})`);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
fetchUncheckedAsync: requestAsync,
|
||||
};
|
||||
}
|
||||
|
||||
public async searchAsync(query: string) {
|
||||
const client = await this.createFetchClientAsync();
|
||||
const response = await client.fetchAsync(this.url("/api/v1/search", { query }));
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const schemaData = await searchSchema.parseAsync(await response.json());
|
||||
|
||||
if (!schemaData.results) {
|
||||
@@ -103,22 +60,17 @@ export class OverseerrIntegration
|
||||
text: "overview" in result ? result.overview : undefined,
|
||||
type: result.mediaType,
|
||||
inLibrary: result.mediaInfo !== undefined,
|
||||
availability: result.mediaInfo
|
||||
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
|
||||
const client = await this.createFetchClientAsync();
|
||||
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
|
||||
const response = await client.fetchAsync(url);
|
||||
const data = await mediaInformationSchema.parseAsync(await response.json());
|
||||
const requestedSeasons = [
|
||||
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
|
||||
];
|
||||
const { mediaInfo: _strip, ...rest } = data;
|
||||
return { ...rest, requestedSeasons };
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
return await mediaInformationSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,9 +80,8 @@ export class OverseerrIntegration
|
||||
* @param seasons A list of the seasons that should be requested.
|
||||
*/
|
||||
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
|
||||
const client = await this.createFetchClientAsync();
|
||||
const url = this.url("/api/v1/request");
|
||||
const response = await client.fetchUncheckedAsync(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mediaType,
|
||||
@@ -138,6 +89,7 @@ export class OverseerrIntegration
|
||||
seasons,
|
||||
}),
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
@@ -163,16 +115,25 @@ export class OverseerrIntegration
|
||||
}
|
||||
|
||||
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
||||
const client = await this.createFetchClientAsync();
|
||||
const pendingRequests = await fetchWithTrustedCertificatesAsync(
|
||||
this.url("/api/v1/request", { take: 20, filter: "pending" }),
|
||||
{
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [pendingResponse, allResponse] = await Promise.all([
|
||||
client.fetchAsync(this.url("/api/v1/request", { take: 20, filter: "pending" })),
|
||||
client.fetchAsync(this.url("/api/v1/request", { take: 20 })),
|
||||
]);
|
||||
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
|
||||
const pendingResults = (await getRequestsSchema.parseAsync(await pendingResponse.json())).results;
|
||||
const allResults = (await getRequestsSchema.parseAsync(await allResponse.json())).results;
|
||||
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
|
||||
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
|
||||
|
||||
//Concat the 2 lists while remove any duplicate pending from the all items list
|
||||
let requests;
|
||||
|
||||
if (pendingResults.length > 0 && allResults.length > 0) {
|
||||
@@ -181,53 +142,37 @@ export class OverseerrIntegration
|
||||
);
|
||||
} else if (pendingResults.length > 0) requests = pendingResults;
|
||||
else if (allResults.length > 0) requests = allResults;
|
||||
else return [];
|
||||
else return Promise.all([]);
|
||||
|
||||
const uniqueKeys = [...new Set(requests.map((request) => `${request.type}:${request.media.tmdbId}`))];
|
||||
const informationByKey = new Map<string, MediaInformation>();
|
||||
return await Promise.all(
|
||||
requests.map(async (request): Promise<MediaRequest> => {
|
||||
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
||||
|
||||
for (const batch of splitToChunksWithNItems(uniqueKeys, DETAIL_FETCH_BATCH_SIZE)) {
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (key) => {
|
||||
const separatorIndex = key.indexOf(":");
|
||||
const type = key.slice(0, separatorIndex) as MediaRequest["type"];
|
||||
const tmdbId = Number(key.slice(separatorIndex + 1));
|
||||
const information = await this.getItemInformationAsync(client, type, tmdbId);
|
||||
return [key, information] as const;
|
||||
}),
|
||||
);
|
||||
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
|
||||
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
|
||||
|
||||
for (const [key, information] of batchResults) {
|
||||
informationByKey.set(key, information);
|
||||
}
|
||||
}
|
||||
|
||||
return requests.map((request): MediaRequest => {
|
||||
const information = informationByKey.get(`${request.type}:${request.media.tmdbId}`)!;
|
||||
|
||||
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
|
||||
|
||||
return {
|
||||
id: request.id,
|
||||
name: information.name,
|
||||
status: this.mapRequestStatus(request.status),
|
||||
availability: this.mapAvailability(request.media.status, inProgress),
|
||||
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
||||
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
||||
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
|
||||
type: request.type,
|
||||
createdAt: request.createdAt,
|
||||
airDate: new Date(information.airDate),
|
||||
requestedBy: request.requestedBy
|
||||
? ({
|
||||
...request.requestedBy,
|
||||
displayName: request.requestedBy.displayName,
|
||||
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
|
||||
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
||||
} satisfies Omit<RequestUser, "requestCount">)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: request.id,
|
||||
name: information.name,
|
||||
status: this.mapRequestStatus(request.status),
|
||||
availability: this.mapAvailability(request.media.status, inProgress),
|
||||
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
||||
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
||||
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
|
||||
type: request.type,
|
||||
createdAt: request.createdAt,
|
||||
airDate: new Date(information.airDate),
|
||||
requestedBy: request.requestedBy
|
||||
? ({
|
||||
...request.requestedBy,
|
||||
displayName: request.requestedBy.displayName,
|
||||
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
|
||||
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
||||
} satisfies Omit<RequestUser, "requestCount">)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
@@ -267,14 +212,20 @@ export class OverseerrIntegration
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<RequestStats> {
|
||||
const client = await this.createFetchClientAsync();
|
||||
const response = await client.fetchAsync(this.url("/api/v1/request/count"));
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
return await getStatsSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
public async getUsersAsync(): Promise<RequestUser[]> {
|
||||
const client = await this.createFetchClientAsync();
|
||||
const response = await client.fetchAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }));
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const users = (await getUsersSchema.parseAsync(await response.json())).results;
|
||||
return users.map((user): RequestUser => {
|
||||
return {
|
||||
@@ -287,58 +238,75 @@ export class OverseerrIntegration
|
||||
|
||||
public async approveRequestAsync(requestId: number): Promise<void> {
|
||||
logger.info("Approving media request", { requestId, integration: this.integration.name });
|
||||
const client = await this.createFetchClientAsync();
|
||||
await client
|
||||
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
||||
method: "POST",
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to approve media request", {
|
||||
requestId,
|
||||
integration: this.integration.name,
|
||||
reason: `${response.status} ${response.statusText}`,
|
||||
url: response.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to approve media request", {
|
||||
requestId,
|
||||
integration: this.integration.name,
|
||||
reason: `${response.status} ${response.statusText}`,
|
||||
url: response.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
|
||||
});
|
||||
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
|
||||
});
|
||||
}
|
||||
|
||||
public async declineRequestAsync(requestId: number): Promise<void> {
|
||||
logger.info("Declining media request", { requestId, integration: this.integration.name });
|
||||
const client = await this.createFetchClientAsync();
|
||||
await client
|
||||
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/decline`), {
|
||||
method: "POST",
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to decline media request", {
|
||||
requestId,
|
||||
integration: this.integration.name,
|
||||
reason: `${response.status} ${response.statusText}`,
|
||||
url: response.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
|
||||
});
|
||||
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to decline media request", {
|
||||
requestId,
|
||||
integration: this.integration.name,
|
||||
reason: `${response.status} ${response.statusText}`,
|
||||
url: response.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
|
||||
});
|
||||
}
|
||||
|
||||
private async getItemInformationAsync(
|
||||
client: OverseerrFetchClient,
|
||||
type: MediaRequest["type"],
|
||||
id: number,
|
||||
): Promise<MediaInformation> {
|
||||
const response = await client.fetchAsync(this.url(`/api/v1/${type}/${id}`));
|
||||
const rawData = (await response.json()) as TvInformation | MovieInformation;
|
||||
return mediaInformationFromResponse[type](rawData as never);
|
||||
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
|
||||
if (type === "tv") {
|
||||
const series = (await response.json()) as TvInformation;
|
||||
return {
|
||||
name: series.name,
|
||||
backdropPath: series.backdropPath ?? series.posterPath,
|
||||
posterPath: series.posterPath ?? series.backdropPath,
|
||||
airDate: series.firstAirDate,
|
||||
} satisfies MediaInformation;
|
||||
}
|
||||
|
||||
const movie = (await response.json()) as MovieInformation;
|
||||
return {
|
||||
name: movie.title,
|
||||
backdropPath: movie.backdropPath ?? movie.posterPath,
|
||||
posterPath: movie.posterPath ?? movie.backdropPath,
|
||||
airDate: movie.releaseDate,
|
||||
} satisfies MediaInformation;
|
||||
}
|
||||
|
||||
private constructAvatarUrl(avatar: string) {
|
||||
@@ -373,37 +341,6 @@ interface MovieInformation {
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
const mediaInformationFromResponse = {
|
||||
tv: (series: TvInformation): MediaInformation => ({
|
||||
name: series.name,
|
||||
backdropPath: series.backdropPath ?? series.posterPath,
|
||||
posterPath: series.posterPath ?? series.backdropPath,
|
||||
airDate: series.firstAirDate,
|
||||
}),
|
||||
movie: (movie: MovieInformation): MediaInformation => ({
|
||||
name: movie.title,
|
||||
backdropPath: movie.backdropPath ?? movie.posterPath,
|
||||
posterPath: movie.posterPath ?? movie.backdropPath,
|
||||
airDate: movie.releaseDate,
|
||||
}),
|
||||
} satisfies Record<MediaRequest["type"], (data: TvInformation | MovieInformation) => MediaInformation>;
|
||||
|
||||
const mediaInfoRequestsSchema = z
|
||||
.object({
|
||||
requests: z
|
||||
.array(
|
||||
z.object({
|
||||
seasons: z.array(
|
||||
z.object({
|
||||
seasonNumber: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const mediaInformationSchema = z.union([
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -418,22 +355,14 @@ const mediaInformationSchema = z.union([
|
||||
),
|
||||
numberOfSeasons: z.number(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
mediaInfo: mediaInfoRequestsSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
overview: z.string(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
mediaInfo: mediaInfoRequestsSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const searchMediaInfoSchema = z
|
||||
.object({
|
||||
status: z.number(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const searchSchema = z.object({
|
||||
results: z
|
||||
.array(
|
||||
@@ -444,7 +373,7 @@ const searchSchema = z.object({
|
||||
name: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -452,14 +381,14 @@ const searchSchema = z.object({
|
||||
title: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
mediaType: z.literal("person"),
|
||||
name: z.string(),
|
||||
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
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";
|
||||
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
|
||||
}
|
||||
|
||||
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
|
||||
const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({
|
||||
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
});
|
||||
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
@@ -33,7 +33,6 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
|
||||
const isPending = isPendingQuery || isPendingMutation;
|
||||
const t = useI18n();
|
||||
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Season>[]>(
|
||||
() => [
|
||||
@@ -45,18 +44,8 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
accessorKey: "episodeCount",
|
||||
header: t("search.engine.media.request.modal.table.header.episodes"),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("search.engine.media.request.modal.table.header.status"),
|
||||
Cell: ({ row }) =>
|
||||
requestedSeasons.has(row.original.seasonNumber) ? (
|
||||
<Badge size="xs" color="violet" variant="light">
|
||||
{t("search.engine.media.request.modal.table.status.requested")}
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[data?.requestedSeasons],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
@@ -67,7 +56,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
enablePagination: false,
|
||||
enableSorting: false,
|
||||
enableSelectAll: true,
|
||||
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
|
||||
enableRowSelection: true,
|
||||
mantineTableProps: {
|
||||
highlightOnHover: false,
|
||||
striped: "odd",
|
||||
|
||||
@@ -44,9 +44,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
setQuery(interaction.query);
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
} else if (interaction.type === "mode") {
|
||||
if (interaction.query !== undefined) {
|
||||
setQuery(interaction.query);
|
||||
}
|
||||
setMode(interaction.mode);
|
||||
} else if (interaction.type === "children") {
|
||||
setChildrenOptions(interaction);
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { OpenMediaRequestSearchOptions } from "../index";
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
|
||||
import {
|
||||
mediaRequestSearchEvent,
|
||||
mediaRequestSearchScopeAtom,
|
||||
selectAction,
|
||||
spotlightActions,
|
||||
spotlightStore,
|
||||
} from "../spotlight-store";
|
||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
@@ -57,35 +49,10 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||
// Works as always the same amount of hooks are executed
|
||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||
const groups = useGroups();
|
||||
const homeEmptyGroups = useHomeEmptyGroups();
|
||||
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleMediaRequestSearch = (event: Event) => {
|
||||
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||
setMediaRequestSearchScope({ integrationIds });
|
||||
setMode("media");
|
||||
setChildrenOptions(null);
|
||||
setQuery(query ?? "");
|
||||
spotlightActions.open();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
};
|
||||
|
||||
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
return () => {
|
||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
};
|
||||
}, [setMediaRequestSearchScope, setMode, setQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "media") {
|
||||
setMediaRequestSearchScope({});
|
||||
}
|
||||
}, [mode, setMediaRequestSearchScope]);
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root
|
||||
@@ -120,13 +87,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.Search
|
||||
placeholder={`${t("search.placeholder")}...`}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
|
||||
leftSection={
|
||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||
<Center w={48} h="100%">
|
||||
<IconSearch stroke={1.5} />
|
||||
</Center>
|
||||
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
|
||||
import { spotlightActions } from "./spotlight-store";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
export { openSpotlight, openMediaRequestSearch };
|
||||
export { openSpotlight };
|
||||
export {
|
||||
SpotlightProvider,
|
||||
useRegisterSpotlightContextResults,
|
||||
@@ -11,18 +11,3 @@ export {
|
||||
} from "./modes/home/context";
|
||||
|
||||
const openSpotlight = spotlightActions.open;
|
||||
|
||||
export interface OpenMediaRequestSearchOptions {
|
||||
integrationIds?: string[];
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||
}
|
||||
|
||||
spotlightActions.open();
|
||||
};
|
||||
|
||||
export { mediaRequestSearchEvent };
|
||||
@@ -12,7 +12,7 @@ const searchInteractions = [
|
||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
|
||||
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
|
||||
createSearchInteraction("children").optionsType<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||
|
||||
@@ -64,36 +64,6 @@ type MediaRequestChildrenProps = {
|
||||
kind: IntegrationKind;
|
||||
url: string;
|
||||
id: string;
|
||||
permissions?: {
|
||||
hasInteractAccess: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const useMediaRequestSearchInteraction = (
|
||||
integration: MediaRequestChildrenProps["integration"],
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -101,6 +71,8 @@ export const useFromIntegrationSearchInteraction = (
|
||||
searchEngine: SearchEngine,
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
if (searchEngine.type !== "fromIntegration") {
|
||||
throw new Error("Invalid search engine type");
|
||||
}
|
||||
@@ -115,7 +87,25 @@ export const useFromIntegrationSearchInteraction = (
|
||||
) &&
|
||||
"type" in searchResult
|
||||
) {
|
||||
return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration: searchEngine.integration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -125,15 +115,13 @@ export const useFromIntegrationSearchInteraction = (
|
||||
};
|
||||
};
|
||||
|
||||
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
useActions() {
|
||||
const { openModal } = useModalAction(RequestMediaModal);
|
||||
return [
|
||||
{
|
||||
key: "request",
|
||||
hide: (option) =>
|
||||
(option.result.type === "movie" && option.result.inLibrary) ||
|
||||
option.integration.permissions?.hasInteractAccess === false,
|
||||
hide: (option) => option.result.inLibrary,
|
||||
Component(option) {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { SearchMode } from "../../lib/mode";
|
||||
import { appIntegrationBoardMode } from "../app-integration-board";
|
||||
import { commandMode } from "../command";
|
||||
import { externalMode } from "../external";
|
||||
import { mediaMode } from "../media";
|
||||
import { pageMode } from "../page";
|
||||
import { userGroupMode } from "../user-group";
|
||||
|
||||
@@ -34,7 +33,7 @@ type QuickLinkOption = {
|
||||
export const useHomeEmptyGroups = () => {
|
||||
const { data: session } = useSession();
|
||||
const tPages = useScopedI18n("search.mode.page.group.page.option");
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
visibleSearchModes.unshift(userGroupMode);
|
||||
@@ -88,7 +87,7 @@ export const useHomeEmptyGroups = () => {
|
||||
useInteraction: interaction.link(({ path }) => ({ href: path })),
|
||||
}),
|
||||
createGroup({
|
||||
keyPath: "modeKey",
|
||||
keyPath: "character",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||
Component: ({ modeKey, character }) => {
|
||||
@@ -97,7 +96,7 @@ export const useHomeEmptyGroups = () => {
|
||||
return (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||
<Text>{t("help")}</Text>
|
||||
{character && <Kbd size="sm">{character}</Kbd>}
|
||||
<Kbd size="sm">{character}</Kbd>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ type GroupItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: TablerIcon | string;
|
||||
disabled?: boolean;
|
||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
};
|
||||
|
||||
@@ -38,7 +37,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{icon}
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
@@ -92,7 +91,9 @@ const createDefaultSearchEntries = (
|
||||
query: string,
|
||||
t: TranslationFunction,
|
||||
): GroupItem[] => {
|
||||
if (!session?.user && !defaultSearchEngine) return [];
|
||||
if (!session?.user && !defaultSearchEngine) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!defaultSearchEngine) {
|
||||
return [
|
||||
|
||||
@@ -2,7 +2,6 @@ import { appIntegrationBoardMode } from "./app-integration-board";
|
||||
import { commandMode } from "./command";
|
||||
import { externalMode } from "./external";
|
||||
import { homeMode } from "./home";
|
||||
import { mediaMode } from "./media";
|
||||
import { pageMode } from "./page";
|
||||
import { userGroupMode } from "./user-group";
|
||||
|
||||
@@ -12,6 +11,5 @@ export const searchModes = [
|
||||
externalMode,
|
||||
commandMode,
|
||||
pageMode,
|
||||
mediaMode,
|
||||
homeMode,
|
||||
] as const;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { mediaRequestSearchGroup } from "./media-request-search-group";
|
||||
|
||||
export const mediaMode = {
|
||||
modeKey: "media",
|
||||
character: undefined,
|
||||
groups: [mediaRequestSearchGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconMovie, IconSearch } from "@tabler/icons-react";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import type { MediaAvailability } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
|
||||
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
|
||||
|
||||
const availabilityLabels: Partial<Record<MediaAvailability, string>> = {
|
||||
available: "Available",
|
||||
partiallyAvailable: "Partial",
|
||||
processing: "Processing",
|
||||
requested: "Requested",
|
||||
pending: "Requested",
|
||||
};
|
||||
|
||||
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
|
||||
|
||||
type MediaRequestSearchOption =
|
||||
| {
|
||||
key: string;
|
||||
kind: "disabled" | "hint";
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: "result";
|
||||
result: MediaRequestSearchResult;
|
||||
};
|
||||
|
||||
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.media.group.title"),
|
||||
Component(option) {
|
||||
if (option.kind !== "result") {
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
|
||||
{option.kind === "disabled" ? <IconMovie stroke={1.5} /> : <IconSearch stroke={1.5} />}
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="xs" c="gray.6">
|
||||
{option.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = option;
|
||||
const badgeLabel = result.availability ? availabilityLabels[result.availability as MediaAvailability] : undefined;
|
||||
const badgeColor = result.availability
|
||||
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{result.image ? (
|
||||
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text>{result.name}</Text>
|
||||
{badgeLabel && (
|
||||
<Badge size="xs" color={badgeColor} variant="light">
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{result.text ?? getIntegrationName(result.integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
if (option.kind !== "result") {
|
||||
return {
|
||||
type: "none",
|
||||
};
|
||||
}
|
||||
|
||||
return useMediaRequestSearchInteraction(option.result.integration, option.result);
|
||||
},
|
||||
useQueryOptions(query) {
|
||||
const tMedia = useScopedI18n("search.mode.media");
|
||||
const scope = useAtomValue(mediaRequestSearchScopeAtom);
|
||||
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
|
||||
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
|
||||
|
||||
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
|
||||
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
|
||||
);
|
||||
const hasScopedTargets = scopedTargets.length > 0;
|
||||
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
|
||||
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
|
||||
|
||||
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
|
||||
{ query: debouncedQuery, integrationIds },
|
||||
{
|
||||
enabled: shouldSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
select: (data) => data.slice(0, 10),
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
|
||||
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
|
||||
|
||||
if (!targetsQuery.data) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasScopedTargets) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [
|
||||
{
|
||||
key: "disabled",
|
||||
kind: "disabled",
|
||||
label: tMedia("action.search.label"),
|
||||
description: scope.integrationIds
|
||||
? tMedia("action.search.disabled.scoped")
|
||||
: tMedia("action.search.disabled.noIntegration"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (debouncedQuery.length === 0) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [
|
||||
{
|
||||
key: "hint",
|
||||
kind: "hint",
|
||||
label: tMedia("action.search.label"),
|
||||
description: tMedia("action.search.description"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: (resultsQuery.data ?? []).map((result) => ({
|
||||
key: `result-${result.integration.id}-${result.type}-${result.id}`,
|
||||
kind: "result",
|
||||
result,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,17 +3,9 @@
|
||||
import { clamp } from "@mantine/hooks";
|
||||
import type { SpotlightStore } from "@mantine/spotlight";
|
||||
import { createSpotlight } from "@mantine/spotlight";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const [spotlightStore, spotlightActions] = createSpotlight();
|
||||
|
||||
export interface MediaRequestSearchScope {
|
||||
integrationIds?: string[];
|
||||
}
|
||||
|
||||
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
|
||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
||||
|
||||
export const setSelectedAction = (index: number, store: SpotlightStore) => {
|
||||
store.updateState((state) => ({ ...state, selected: index }));
|
||||
};
|
||||
|
||||
@@ -4551,23 +4551,9 @@
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"help": "Search for movies or shows to request",
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": "",
|
||||
"group": {
|
||||
"title": "Movies and shows"
|
||||
},
|
||||
"action": {
|
||||
"search": {
|
||||
"label": "Search for movies or shows",
|
||||
"description": "Start typing to search your media request integrations",
|
||||
"disabled": {
|
||||
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
|
||||
"scoped": "This widget does not have an accessible media request integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
@@ -4843,11 +4829,7 @@
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "Season",
|
||||
"episodes": "Episodes",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Requested"
|
||||
"episodes": "Episodes"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -1405,6 +1405,9 @@
|
||||
"openNewTab": {
|
||||
"label": "Open in new tab"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": "Show border"
|
||||
},
|
||||
"items": {
|
||||
"label": "Bookmarks",
|
||||
"add": "Add bookmark"
|
||||
@@ -4551,23 +4554,9 @@
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"help": "Search for movies or shows to request",
|
||||
"requestMovie": "Request movie",
|
||||
"requestSeries": "Request series",
|
||||
"openIn": "Open in {kind}",
|
||||
"group": {
|
||||
"title": "Movies and shows"
|
||||
},
|
||||
"action": {
|
||||
"search": {
|
||||
"label": "Search for movies or shows",
|
||||
"description": "Start typing to search your media request integrations",
|
||||
"disabled": {
|
||||
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
|
||||
"scoped": "This widget does not have an accessible media request integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
"openIn": "Open in {kind}"
|
||||
},
|
||||
"external": {
|
||||
"help": "Use an external search engine",
|
||||
@@ -4843,11 +4832,7 @@
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "Season",
|
||||
"episodes": "Episodes",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Requested"
|
||||
"episodes": "Episodes"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -1056,7 +1056,7 @@
|
||||
}
|
||||
},
|
||||
"noResults": {
|
||||
"title": ""
|
||||
"title": "Nessun media caricato"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1088,8 +1088,8 @@
|
||||
"checkLogs": "Controlla i log per maggiori dettagli",
|
||||
"tryAgain": "Riprova",
|
||||
"loading": "Caricamento",
|
||||
"show": "",
|
||||
"hide": ""
|
||||
"show": "Visualizza",
|
||||
"hide": "Nascondi"
|
||||
},
|
||||
"here": "qui",
|
||||
"iconPicker": {
|
||||
@@ -1255,7 +1255,7 @@
|
||||
"label": {
|
||||
"create": "Nuova categoria",
|
||||
"changePosition": "Cambia posizione",
|
||||
"settings": ""
|
||||
"settings": "Impostazioni"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"private": "Questa board è privata"
|
||||
},
|
||||
"noResults": {
|
||||
"title": ""
|
||||
"title": "Ancora nessuna board"
|
||||
},
|
||||
"modal": {
|
||||
"createBoard": {
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"form": {
|
||||
"name": {
|
||||
"label": "Gruppens namn",
|
||||
"description": ""
|
||||
"description": "Namnet måste matcha admin-grupp i extern tjänst"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -159,7 +159,7 @@
|
||||
"label": "Visa sökresultat i ny flik"
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": ""
|
||||
"label": "Aktivera DuckDuckGo bangs"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -252,10 +252,10 @@
|
||||
"changeDdgBangs": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "DuckDuckGo bangs inställningen ändrad"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "DuckDuckGo bangs inställningen kunde inte ändras"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,12 +350,12 @@
|
||||
"description": "Tillåt medlemmar att addera valfri applikation på sina tavlor"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Modifiera alla appar",
|
||||
"description": "Tillåt att medlemmar modifierar alla appar"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Fullständiga rättigheter till appar",
|
||||
"description": "Tillåt att medlemmar hanterar, använder och raderar alla appar"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -363,20 +363,20 @@
|
||||
"title": "Tavlor",
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Skapa tavlor",
|
||||
"description": "Tillåt att medlemmar skapar tavlor"
|
||||
},
|
||||
"view-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Se alla tavlor",
|
||||
"description": "Tillåt att medlemmar ser alla tavlor"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Modifiera alla tavlor",
|
||||
"description": "Tillåt att medlemmar modifierar alla tavlor (Detta inkluderar inte åtkomstkontroll eller farozonen)"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Fullständiga rättigheter till tavlor",
|
||||
"description": "Tillåt att medlemmar ser, modifierar och tar bort alla tavlor (detta inkluderar åtkomstkontroll och farozonen)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -392,38 +392,38 @@
|
||||
"description": "Tillåter medlemmar att addera valfri integration till sina tavlor"
|
||||
},
|
||||
"interact-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Interagera med alla integrationer",
|
||||
"description": "Tillåt att medlemmar interagerar med alla integrationer"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Fullständiga rättigheter till integrationer",
|
||||
"description": "Tillåt att medlemmar hanterar, använder och interagerar med alla integrationer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"title": "",
|
||||
"title": "Media",
|
||||
"item": {
|
||||
"upload": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Ladda upp media",
|
||||
"description": "Tillåt att medlemmar laddar upp media"
|
||||
},
|
||||
"view-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Se all media",
|
||||
"description": "Tillåt att medlemmar ser all media"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Fullständiga rättigheter till media",
|
||||
"description": "Tillåt att medlemmar hanterar och raderar all media"
|
||||
}
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"title": "",
|
||||
"title": "Annat",
|
||||
"item": {
|
||||
"view-logs": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Se loggar",
|
||||
"description": "Tillåt att medlemmar ser loggar"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -435,32 +435,32 @@
|
||||
"description": "Tillåt medlemmar att skapa sökmotorer"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Modifiera alla sökmotorer",
|
||||
"description": "Tillåt att medlemmar modifierar alla sökmotorer"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Fullständiga rättigheter till sökmotorer",
|
||||
"description": "Tillåt att medlemmar hanterar och raderar alla sökmotorer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberNotice": {
|
||||
"mixed": "",
|
||||
"external": ""
|
||||
"mixed": "Vissa medlemmar är från externa tjänster och kan inte hantera här",
|
||||
"external": "Alla medlemmar är från externa tjänster och kan inte hanteras här"
|
||||
},
|
||||
"reservedNotice": {
|
||||
"message": ""
|
||||
"message": "Denna grupp är reserverad för systemet så vissa åtgärder är begränsade. <checkoutDocs></checkoutDocs>"
|
||||
},
|
||||
"action": {
|
||||
"create": {
|
||||
"label": "Addera en grupp",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Gruppen har skapats"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Gruppen kunde inte skapas"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -470,7 +470,7 @@
|
||||
"confirm": "Är du säker på att du vill överföra ägarskapet för gruppen {name} till {username}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Överfört grupp {group} till {user}"
|
||||
},
|
||||
"error": {
|
||||
"message": "Kunde inte överföra ägarskap"
|
||||
@@ -501,38 +501,38 @@
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Behörigheter sparade",
|
||||
"message": ""
|
||||
"message": "Rättigheterna sparade"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Rättigheterna ej sparade",
|
||||
"message": "Rättigheterna har inte sparats"
|
||||
}
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Gruppen {name} har sparats"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Kunde inte spara gruppen {name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"label": "Välj en grupp",
|
||||
"notFound": ""
|
||||
"notFound": "Ingen grupp hittad"
|
||||
},
|
||||
"settings": {
|
||||
"board": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Inställningarna sparades",
|
||||
"message": ""
|
||||
"message": "Inställningarna för tavlan har sparats"
|
||||
},
|
||||
"error": {
|
||||
"title": "Kunde inte spara inställningarna",
|
||||
"message": ""
|
||||
"message": "Kunde inte spara inställningarna för tavlan"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,10 +540,10 @@
|
||||
"changePosition": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Ordning ändrad"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Ordningen kunde ändras"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,20 +559,20 @@
|
||||
"list": {
|
||||
"title": "Applikationer",
|
||||
"noResults": {
|
||||
"title": "",
|
||||
"action": ""
|
||||
"title": "Det finns inga appar ännu",
|
||||
"action": "Skapa din första app"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"title": "Addera applikation",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Skapandet lyckades",
|
||||
"message": "Appen skapades framgångsrikt"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Skapandet misslyckades",
|
||||
"message": "Appen kunde inte skapas"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -581,11 +581,11 @@
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Ändringarna har sparats",
|
||||
"message": ""
|
||||
"message": "Appen sparades"
|
||||
},
|
||||
"error": {
|
||||
"title": "Misslyckades med att spara ändringarna",
|
||||
"message": ""
|
||||
"message": "Appen kunde inte sparas"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -594,12 +594,12 @@
|
||||
"message": "Är du säker på att du vill ta bort applikationen {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Borttagning lyckades",
|
||||
"message": "Appen har tagits bort"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Borttagning misslyckades",
|
||||
"message": "Kunde inte ta bort appen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,21 +612,21 @@
|
||||
"label": "Beskrivning"
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
"label": "URL"
|
||||
},
|
||||
"useDifferentUrlForPing": {
|
||||
"checkbox": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Använd annan URL för ping",
|
||||
"description": "Användbart om Homarr kan komma åt appen direkt via ett internt host-namn eller nätverk för att undvika bandbreddsanvändning hos internetleverantör"
|
||||
}
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"select": {
|
||||
"label": "",
|
||||
"notFound": "",
|
||||
"label": "Välj app",
|
||||
"notFound": "Ingen app hittades",
|
||||
"search": "Sök efter en applikation",
|
||||
"noResults": "",
|
||||
"noResults": "Inga resultat",
|
||||
"action": "Välj {app}",
|
||||
"title": "Välj en applikation att lägga till på tavlan"
|
||||
},
|
||||
@@ -642,56 +642,56 @@
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "Integrationer",
|
||||
"search": "",
|
||||
"search": "Sök integrationer",
|
||||
"noResults": {
|
||||
"title": ""
|
||||
"title": "Det finns inga integrationer ännu"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"title": "",
|
||||
"title": "Ny {name} integration",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Skapandet lyckades",
|
||||
"message": "Integrationen har skapats"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Skapandet misslyckades",
|
||||
"message": "Integrationen kunde inte skapas"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"option": {
|
||||
"existing": {
|
||||
"title": "",
|
||||
"label": ""
|
||||
"title": "Befintlig",
|
||||
"label": "Välj befintlig app"
|
||||
},
|
||||
"new": {
|
||||
"title": "",
|
||||
"title": "Ny",
|
||||
"url": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "App URL",
|
||||
"description": "URL:en som appen kommer att öppna när den används från tavlan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "",
|
||||
"title": "Redigera {name} integration",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Ändringarna har sparats",
|
||||
"message": ""
|
||||
"message": "Integrationen sparad"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Kunde inte tillämpa ändringar",
|
||||
"message": "Integrationen kunde inte sparas"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"action": {
|
||||
"add": "",
|
||||
"remove": "",
|
||||
"select": ""
|
||||
"add": "Länka en app",
|
||||
"remove": "Ta bort länk",
|
||||
"select": "Välj en app att länka till"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -700,12 +700,12 @@
|
||||
"message": "Är du säker på att du vill ta bort integrationen {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Borttagning lyckades",
|
||||
"message": "Integrationen har tagits bort"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Borttagning misslyckades",
|
||||
"message": "Kunde inte ta bort integrationen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,44 +715,44 @@
|
||||
"label": "Namn"
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
"label": "URL"
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Skapa sökmotor",
|
||||
"description": "Integrationen \"{kind}\" kan inte användas med sökmotorer. Markera detta för att automatiskt konfigurera sökmotorn."
|
||||
},
|
||||
"app": {
|
||||
"sectionTitle": ""
|
||||
"sectionTitle": "Länkad app"
|
||||
},
|
||||
"createApp": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Skapa app",
|
||||
"description": "Skapa en app med samma namn och ikon som integrationen. Lämna fältet nedan tomt för att skapa en app med integrationens URL."
|
||||
},
|
||||
"appHref": {
|
||||
"placeholder": ""
|
||||
"placeholder": "Egen app-URL"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": ""
|
||||
"create": "Ny integration"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"edit": ""
|
||||
"create": "Testa anslutningen och skapa",
|
||||
"edit": "Testa anslutningen och skapa"
|
||||
},
|
||||
"error": {
|
||||
"common": {
|
||||
"cause": {
|
||||
"title": ""
|
||||
"title": "Orsak med mer detaljer"
|
||||
}
|
||||
},
|
||||
"unknown": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Okänt fel",
|
||||
"description": "Ett okänt fel inträffade, öppna orsaken nedan för att se mer detaljer"
|
||||
},
|
||||
"parse": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Parsningsfel",
|
||||
"description": "Svaret kunde inte analyseras. Verifiera att URL pekar på bas-URL för tjänsten."
|
||||
},
|
||||
"authorization": {
|
||||
"title": "",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
|
||||
const board = useRequiredBoard();
|
||||
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
|
||||
select(data) {
|
||||
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={options.hideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
@@ -67,6 +68,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={options.hideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
@@ -81,6 +83,7 @@ interface FlexLayoutProps {
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
}
|
||||
|
||||
@@ -91,6 +94,7 @@ const FlexLayout = ({
|
||||
hideIcon,
|
||||
hideHostname,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
}: FlexLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
@@ -106,7 +110,15 @@ const FlexLayout = ({
|
||||
key={app.id}
|
||||
w="100%"
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={4} h="100%">
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
@@ -138,6 +150,7 @@ interface GridLayoutProps {
|
||||
hideIcon: boolean;
|
||||
hideHostname: boolean;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
itemDirection: "horizontal" | "vertical";
|
||||
hasIconColor: boolean;
|
||||
}
|
||||
@@ -148,6 +161,7 @@ const GridLayout = ({
|
||||
hideIcon,
|
||||
hideHostname,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
itemDirection,
|
||||
hasIconColor,
|
||||
}: GridLayoutProps) => {
|
||||
@@ -168,6 +182,7 @@ const GridLayout = ({
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
|
||||
@@ -24,6 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
hideIcon: factory.switch({ defaultValue: false }),
|
||||
hideHostname: factory.switch({ defaultValue: false }),
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
withBorder: factory.switch({ defaultValue: false }),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
|
||||
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { MediaRequestStatus } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
|
||||
export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
@@ -58,7 +43,7 @@ export default function MediaServerWidget({
|
||||
const newData = filteredData.concat(
|
||||
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
|
||||
);
|
||||
return newData.toSorted((dataA, dataB) => {
|
||||
return newData.sort((dataA, dataB) => {
|
||||
if (dataA.status === dataB.status) {
|
||||
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
|
||||
}
|
||||
@@ -76,46 +61,25 @@ export default function MediaServerWidget({
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={classes.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
|
||||
isTiny: boolean;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.searchRoot {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
position: absolute;
|
||||
top: var(--mantine-spacing-xs);
|
||||
right: var(--mantine-spacing-xs);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.searchRoot:hover .searchButton,
|
||||
.searchRoot:focus-within .searchButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.searchButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceTv,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IconMovie,
|
||||
IconPlayerPlay,
|
||||
IconReceipt,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -18,13 +17,11 @@ import combineClasses from "clsx";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { RequestStats } from "@homarr/integrations/types";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
const OVERSEERR_COLOR = "#ECB000";
|
||||
const JELLYSEERR_COLOR = "#6677CC";
|
||||
@@ -96,104 +93,83 @@ export default function MediaServerWidget({
|
||||
const isTiny = width < 256;
|
||||
|
||||
return (
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={searchClasses.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Generated
+483
-507
File diff suppressed because it is too large.
Load diff
+30
-30
@@ -6,7 +6,7 @@ catalog:
|
||||
"@auth/core": ^0.41.2
|
||||
"@auth/drizzle-adapter": ^1.11.2
|
||||
"@ctrl/deluge": ^8.0.0
|
||||
"@ctrl/qbittorrent": ^9.12.1
|
||||
"@ctrl/qbittorrent": ^9.12.2
|
||||
"@ctrl/transmission": ^7.4.0
|
||||
"@dnd-kit/core": ^6.3.1
|
||||
"@dnd-kit/modifiers": ^9.0.0
|
||||
@@ -20,17 +20,17 @@ catalog:
|
||||
"@immich/sdk": ^2.7.5
|
||||
"@jellyfin/sdk": ^0.13.0
|
||||
"@kubernetes/client-node": ^1.4.0
|
||||
"@mantine/charts": ^9.2.1
|
||||
"@mantine/colors-generator": ^9.2.1
|
||||
"@mantine/core": ^9.2.1
|
||||
"@mantine/dates": ^9.2.1
|
||||
"@mantine/dropzone": ^9.2.1
|
||||
"@mantine/form": ^9.2.1
|
||||
"@mantine/hooks": ^9.2.1
|
||||
"@mantine/modals": ^9.2.1
|
||||
"@mantine/notifications": ^9.2.1
|
||||
"@mantine/spotlight": ^9.2.1
|
||||
"@mantine/tiptap": ^9.2.1
|
||||
"@mantine/charts": ^9.2.2
|
||||
"@mantine/colors-generator": ^9.2.2
|
||||
"@mantine/core": ^9.2.2
|
||||
"@mantine/dates": ^9.2.2
|
||||
"@mantine/dropzone": ^9.2.2
|
||||
"@mantine/form": ^9.2.2
|
||||
"@mantine/hooks": ^9.2.2
|
||||
"@mantine/modals": ^9.2.2
|
||||
"@mantine/notifications": ^9.2.2
|
||||
"@mantine/spotlight": ^9.2.2
|
||||
"@mantine/tiptap": ^9.2.2
|
||||
"@next/eslint-plugin-next": 16.2.6
|
||||
"@octokit/auth-app": ^8.2.0
|
||||
"@paralleldrive/cuid2": ^3.3.0
|
||||
@@ -43,12 +43,12 @@ catalog:
|
||||
"@semantic-release/release-notes-generator": ^14.1.1
|
||||
"@t3-oss/env-nextjs": ^0.13.11
|
||||
"@tabler/icons-react": ^3.44.0
|
||||
"@tanstack/react-query": ^5.100.13
|
||||
"@tanstack/react-query-devtools": ^5.100.13
|
||||
"@tanstack/react-query-next-experimental": ^5.100.13
|
||||
"@testcontainers/mysql": ^12.0.0
|
||||
"@testcontainers/postgresql": ^12.0.0
|
||||
"@testcontainers/redis": ^12.0.0
|
||||
"@tanstack/react-query": ^5.100.14
|
||||
"@tanstack/react-query-devtools": ^5.100.14
|
||||
"@tanstack/react-query-next-experimental": ^5.100.14
|
||||
"@testcontainers/mysql": ^12.0.1
|
||||
"@testcontainers/postgresql": ^12.0.1
|
||||
"@testcontainers/redis": ^12.0.1
|
||||
"@tiptap/extension-color": 3.23.6
|
||||
"@tiptap/extension-highlight": 3.23.6
|
||||
"@tiptap/extension-image": 3.23.6
|
||||
@@ -70,7 +70,7 @@ catalog:
|
||||
"@trpc/react-query": ^11.17.0
|
||||
"@trpc/server": ^11.17.0
|
||||
"@trpc/tanstack-react-query": ^11.17.0
|
||||
"@turbo/gen": ^2.9.14
|
||||
"@turbo/gen": ^2.9.16
|
||||
"@types/adm-zip": 0.5.8
|
||||
"@types/bcrypt": 6.0.0
|
||||
"@types/better-sqlite3": 7.6.13
|
||||
@@ -109,7 +109,7 @@ catalog:
|
||||
croner: 10.0.1
|
||||
cross-env: ^10.1.0
|
||||
crypto-js: ^4.2.0
|
||||
dayjs: ^1.11.20
|
||||
dayjs: ^1.11.21
|
||||
deepmerge: 4.3.1
|
||||
dns-caching: ^0.2.9
|
||||
dockerode: ^5.0.0
|
||||
@@ -124,30 +124,30 @@ catalog:
|
||||
flag-icons: ^7.5.0
|
||||
glob: ^13.0.6
|
||||
ical.js: ^2.2.1
|
||||
ioredis: 5.10.1
|
||||
isomorphic-dompurify: ^3.14.0
|
||||
ioredis: 5.11.0
|
||||
isomorphic-dompurify: ^3.15.0
|
||||
jotai: ^2.20.0
|
||||
jsdom: ^29.1.1
|
||||
json5: ^2.2.3
|
||||
ldapts: 8.1.8
|
||||
lodash.clonedeep: ^4.5.0
|
||||
mantine-react-table: 2.0.0-beta.9
|
||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||
maria2: ^0.4.1
|
||||
mysql2: 3.22.3
|
||||
mysql2: 3.22.4
|
||||
next: 16.2.6
|
||||
next-auth: 5.0.0-beta.31
|
||||
next-intl: 4.12.0
|
||||
next-intl: 4.13.0
|
||||
node-loader: ^2.1.0
|
||||
octokit: ^5.0.5
|
||||
oxfmt: ^0.51.0
|
||||
oxlint: ^1.66.0
|
||||
oxfmt: ^0.52.0
|
||||
oxlint: ^1.67.0
|
||||
pg: ^8.21.0
|
||||
postcss-preset-mantine: ^1.18.0
|
||||
prismjs: ^1.30.0
|
||||
proxmox-api: 1.1.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6
|
||||
react-error-boundary: ^6.1.1
|
||||
react-error-boundary: ^6.1.2
|
||||
react-markdown: ^10.1.0
|
||||
react-quill-new: ^3.8.3
|
||||
react-simple-code-editor: ^0.14.1
|
||||
@@ -158,12 +158,12 @@ catalog:
|
||||
superjson: 2.2.6
|
||||
svgson: ^5.3.1
|
||||
swagger-ui-react: ^5.32.6
|
||||
testcontainers: ^12.0.0
|
||||
testcontainers: ^12.0.1
|
||||
timezones-ical-library: ^2.2.0
|
||||
trpc-to-openapi: ^3.3.0
|
||||
tsdav: ^2.2.2
|
||||
tsx: 4.20.4
|
||||
turbo: ^2.9.14
|
||||
turbo: ^2.9.16
|
||||
typescript: ^5.9.3
|
||||
undici: 7.24.6
|
||||
use-deep-compare-effect: ^1.8.1
|
||||
|
||||
Reference in new issue
Block a user