Compare commits

..
Author SHA1 Message Date
Thomas 088b4b58b8 Merge branch 'dev' into agent/124231b2 2026-06-03 08:45:24 +02:00
homarr-renovate[bot] 3ff0d72ba1 chore(deps): update dependency next-intl to v4.13.0 (#5853)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-03 04:21:57 +00:00
homarr-renovate[bot] a63a191203 chore(deps): update turbo monorepo to ^2.9.16 (#5851)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 23:54:09 +00:00
homarr-renovate[bot] 1be906a0cd chore(deps): update dependency isomorphic-dompurify to ^3.15.0 (#5852)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 21:55:53 +00:00
ajnart 65faf99d9c style: auto-fix lint and format 2026-06-02 21:22:03 +00:00
Thomas Camlong 4b35c31ecc fix(docker): restore container selection toolbar broken by Mantine v9
Replace mantine-react-table@2.0.0-beta.9 with mantine-react-table-open@9.0.3
(aliased via npm:) which properly supports Mantine v9's Collapse `expanded`
prop (renamed from `in`). Also extract hooks out of renderTopToolbarCustomActions
to fix a React rules-of-hooks violation.
2026-06-02 23:20:55 +02:00
homarr-renovate[bot] 63d8fa8c26 chore(deps): update dependency @ctrl/qbittorrent to ^9.12.2 (#5834)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 18:47:29 +00:00
homarr-renovate[bot] b5dd6ae8ef chore(deps): update babel monorepo to >=7.29.7 (#5830)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 14:11:26 +00:00
homarr-renovate[bot] d540ad374a chore(deps): update pnpm to v10.34.0 (#5846)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 09:17:24 +00:00
homarr-renovate[bot] 6a971c1986 chore(deps): update mantine monorepo to ^9.2.2 (#5844)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 04:13:28 +00:00
homarr-crowdin[bot] d86fe3ddf0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-06-02 02:23:34 +00:00
homarr-renovate[bot] 06db8960a7 chore(deps): update turbo monorepo to ^2.9.15 (#5841)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 23:12:09 +00:00
homarr-renovate[bot] a5719acf34 chore(deps): update dependency tmp@<=0.2.3 to >=0.2.7 (#5848)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 21:06:21 +00:00
homarr-renovate[bot] d1d597c8e1 chore(deps): update testcontainers-node monorepo to ^12.0.1 (#5845)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 16:48:41 +00:00
homarr-renovate[bot] 657e42e4bb chore(deps): update dependency mysql2 to v3.22.4 (#5839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 18:12:08 +00:00
homarr-renovate[bot] 256ebcb864 chore(deps): update dependency oxlint to ^1.67.0 (#5837)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 16:18:04 +00:00
homarr-renovate[bot] 72e86dee01 chore(deps): update dependency ioredis to v5.11.0 (#5838)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 14:53:18 +00:00
homarr-renovate[bot] b7910b16e0 chore(deps): update dependency oxfmt to ^0.52.0 (#5836)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 11:08:29 +00:00
homarr-renovate[bot] 5a83bb6e39 chore(deps): update dependency dayjs to ^1.11.21 (#5835)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 08:54:20 +00:00
homarr-renovate[bot] 433b66582d chore(deps): update dependency hono@<4.6.5 to >=4.12.23 (#5828)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 09:25:22 +00:00
homarr-renovate[bot] acbcb6818b chore(deps): update dependency react-error-boundary to ^6.1.2 (#5829)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 06:54:05 +00:00
homarr-crowdin[bot] 07d991a0a1 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-05-30 05:27:45 +00:00
homarr-renovate[bot] 92364e9574 chore(deps): update tanstack-query monorepo to ^5.100.14 (#5813)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-29 21:15:51 +00:00
31 changed files with 1003 additions and 1695 deletions

No files matched your search

@@ -148,9 +148,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
); );
}; };
const integrationCategories = integrationDefs[searchParams.kind].category.flat(); const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
const relativeTime = useTimeAgo(data.timestamp); 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({ const table = useTranslatedMantineReactTable({
data: data.containers, data: data.containers,
enableDensityToggle: false, enableDensityToggle: false,
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
}, },
initialState: { density: "xs", showGlobalFilter: true }, initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => { renderTopToolbarCustomActions: () => (
const utils = clientApi.useUtils(); <Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ {tDocker("action.refresh.label")}
async onSuccess() { </Button>
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>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => { renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original); const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return ( return (
+2 -2
View File
@@ -6,9 +6,9 @@
}, },
"engines": { "engines": {
"node": ">=24.16.0", "node": ">=24.16.0",
"pnpm": ">=10.33.4" "pnpm": ">=10.34.1"
}, },
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.34.1",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"better-sqlite3" "better-sqlite3"
+6 -6
View File
@@ -60,9 +60,9 @@
}, },
"engines": { "engines": {
"node": ">=24.16.0", "node": ">=24.16.0",
"pnpm": ">=10.33.4" "pnpm": ">=10.34.1"
}, },
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.34.1",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml", "@tree-sitter-grammars/tree-sitter-yaml",
@@ -76,14 +76,14 @@
"tree-sitter-json" "tree-sitter-json"
], ],
"overrides": { "overrides": {
"@babel/helpers@<7.26.10": ">=7.29.2", "@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.2", "@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1", "axios@>=1.0.0 <1.8.2": ">=1.16.1",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6", "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6", "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0", "esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5", "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", "linkifyjs@<4.3.2": ">=4.3.3",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
@@ -93,7 +93,7 @@
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=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" "vite@>=5.0.0 <=5.4.18": ">=8.0.14"
}, },
"patchedDependencies": { "patchedDependencies": {
@@ -1,8 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -16,9 +14,11 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories, integrationCategories,
@@ -33,7 +33,6 @@ import {
integrationSavePermissionsSchema, integrationSavePermissionsSchema,
integrationUpdateSchema, integrationUpdateSchema,
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; 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"; import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -82,7 +80,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.toSorted( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -130,7 +128,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.toSorted( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -181,7 +179,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.toSorted( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -316,7 +314,20 @@ export const integrationRouter = createTRPCRouter({
url: input.url, 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 }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -484,7 +495,7 @@ export const integrationRouter = createTRPCRouter({
}); });
return { return {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
users: userPermissions users: userPermissions
@@ -492,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
user, user,
permission, permission,
})) }))
.toSorted((permissionA, permissionB) => { .sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}), }),
groups: dbGroupIntegrationPermission groups: dbGroupIntegrationPermission
@@ -503,7 +514,7 @@ export const integrationRouter = createTRPCRouter({
}, },
permission, permission,
})) }))
.toSorted((permissionA, permissionB) => { .sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
}; };
@@ -607,149 +618,8 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query)); 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 { interface UpdateSecretInput {
integrationId: string; integrationId: string;
value: string; value: string;
@@ -770,6 +640,35 @@ interface AddSecretInput {
kind: IntegrationSecretKind; 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) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,
@@ -6,9 +6,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; 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"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" }); const logger = createLogger({ module: "searchEngineRouter" });
@@ -145,6 +148,20 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, 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 create: permissionRequiredProcedure
.requiresPermission("search-engine-create") .requiresPermission("search-engine-create")
.input(searchEngineManageSchema) .input(searchEngineManageSchema)
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; 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 { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; 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", () => { describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => { test("with full access should return an integration by id", async () => {
const db = createDb(); const db = createDb();
@@ -329,7 +208,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); 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 db = createDb();
const caller = integrationRouter.createCaller({ const caller = integrationRouter.createCaller({
db, db,
@@ -364,7 +243,12 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow); 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 () => { 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, { export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
expectedMaximumDurationInMillis: 15_000,
}).withCallback(
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, { createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
widgetKinds: ["mediaRequests-requestList"], widgetKinds: ["mediaRequests-requestList"],
getInput: { getInput: {
@@ -12,14 +12,12 @@ interface SeriesInformation {
overview: string; overview: string;
seasons: SerieSeason[]; seasons: SerieSeason[];
posterPath: string; posterPath: string;
requestedSeasons: number[];
} }
interface MovieInformation { interface MovieInformation {
id: number; id: number;
overview: string; overview: string;
posterPath: string; posterPath: string;
requestedSeasons: number[];
} }
export type MediaInformation = SeriesInformation | MovieInformation; export type MediaInformation = SeriesInformation | MovieInformation;
@@ -1,10 +1,6 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { RequestInit, Response } from "undici";
import { fetch } from "undici";
import { splitToChunksWithNItems } from "@homarr/common"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -28,14 +24,6 @@ import {
const logger = createLogger({ module: "overseerrIntegration" }); 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 { interface OverseerrSearchResult {
id: number; id: number;
name: string; name: string;
@@ -52,43 +40,12 @@ export class OverseerrIntegration
extends Integration extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult> 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) { public async searchAsync(query: string) {
const client = await this.createFetchClientAsync(); const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
const response = await client.fetchAsync(this.url("/api/v1/search", { query })); headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const schemaData = await searchSchema.parseAsync(await response.json()); const schemaData = await searchSchema.parseAsync(await response.json());
if (!schemaData.results) { if (!schemaData.results) {
@@ -103,22 +60,17 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined, text: "overview" in result ? result.overview : undefined,
type: result.mediaType, type: result.mediaType,
inLibrary: result.mediaInfo !== undefined, inLibrary: result.mediaInfo !== undefined,
availability: result.mediaInfo
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
: undefined,
})); }));
} }
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) { 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 url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await client.fetchAsync(url); const response = await fetchWithTrustedCertificatesAsync(url, {
const data = await mediaInformationSchema.parseAsync(await response.json()); headers: {
const requestedSeasons = [ "X-Api-Key": this.getSecretValue("apiKey"),
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []), },
]; });
const { mediaInfo: _strip, ...rest } = data; return await mediaInformationSchema.parseAsync(await response.json());
return { ...rest, requestedSeasons };
} }
/** /**
@@ -128,9 +80,8 @@ export class OverseerrIntegration
* @param seasons A list of the seasons that should be requested. * @param seasons A list of the seasons that should be requested.
*/ */
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> { 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 url = this.url("/api/v1/request");
const response = await client.fetchUncheckedAsync(url, { const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
mediaType, mediaType,
@@ -138,6 +89,7 @@ export class OverseerrIntegration
seasons, seasons,
}), }),
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@@ -163,16 +115,25 @@ export class OverseerrIntegration
} }
public async getRequestsAsync(): Promise<MediaRequest[]> { 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([ const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
client.fetchAsync(this.url("/api/v1/request", { take: 20, filter: "pending" })), headers: {
client.fetchAsync(this.url("/api/v1/request", { take: 20 })), "X-Api-Key": this.getSecretValue("apiKey"),
]); },
});
const pendingResults = (await getRequestsSchema.parseAsync(await pendingResponse.json())).results; const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allResponse.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; let requests;
if (pendingResults.length > 0 && allResults.length > 0) { if (pendingResults.length > 0 && allResults.length > 0) {
@@ -181,53 +142,37 @@ export class OverseerrIntegration
); );
} else if (pendingResults.length > 0) requests = pendingResults; } else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults; 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}`))]; return await Promise.all(
const informationByKey = new Map<string, MediaInformation>(); 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)) { // See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
const batchResults = await Promise.all( const inProgress = (request.media.downloadStatus ?? []).length >= 1;
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;
}),
);
for (const [key, information] of batchResults) { return {
informationByKey.set(key, information); id: request.id,
} name: information.name,
} status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
return requests.map((request): MediaRequest => { backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
const information = informationByKey.get(`${request.type}:${request.media.tmdbId}`)!; posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
const inProgress = (request.media.downloadStatus ?? []).length >= 1; type: request.type,
createdAt: request.createdAt,
return { airDate: new Date(information.airDate),
id: request.id, requestedBy: request.requestedBy
name: information.name, ? ({
status: this.mapRequestStatus(request.status), ...request.requestedBy,
availability: this.mapAvailability(request.media.status, inProgress), displayName: request.requestedBy.displayName,
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`, link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`, avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(), } satisfies Omit<RequestUser, "requestCount">)
type: request.type, : undefined,
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 { protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -267,14 +212,20 @@ export class OverseerrIntegration
} }
public async getStatsAsync(): Promise<RequestStats> { public async getStatsAsync(): Promise<RequestStats> {
const client = await this.createFetchClientAsync(); const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
const response = await client.fetchAsync(this.url("/api/v1/request/count")); headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
return await getStatsSchema.parseAsync(await response.json()); return await getStatsSchema.parseAsync(await response.json());
} }
public async getUsersAsync(): Promise<RequestUser[]> { public async getUsersAsync(): Promise<RequestUser[]> {
const client = await this.createFetchClientAsync(); const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
const response = await client.fetchAsync(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; const users = (await getUsersSchema.parseAsync(await response.json())).results;
return users.map((user): RequestUser => { return users.map((user): RequestUser => {
return { return {
@@ -287,58 +238,75 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> { public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name }); logger.info("Approving media request", { requestId, integration: this.integration.name });
const client = await this.createFetchClientAsync(); await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
await client method: "POST",
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/approve`), { headers: {
method: "POST", "X-Api-Key": this.getSecretValue("apiKey"),
}) },
.then((response) => { }).then((response) => {
if (!response.ok) { if (!response.ok) {
logger.error( logger.error(
new ErrorWithMetadata("Failed to approve media request", { new ErrorWithMetadata("Failed to approve media request", {
requestId, requestId,
integration: this.integration.name, integration: this.integration.name,
reason: `${response.status} ${response.statusText}`, reason: `${response.status} ${response.statusText}`,
url: response.url, 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> { public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name }); 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( private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
client: OverseerrFetchClient, const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
type: MediaRequest["type"], headers: {
id: number, "X-Api-Key": this.getSecretValue("apiKey"),
): 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); 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) { private constructAvatarUrl(avatar: string) {
@@ -373,37 +341,6 @@ interface MovieInformation {
releaseDate: string; 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([ const mediaInformationSchema = z.union([
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -418,22 +355,14 @@ const mediaInformationSchema = z.union([
), ),
numberOfSeasons: z.number(), numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
overview: z.string(), overview: z.string(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
]); ]);
const searchMediaInfoSchema = z
.object({
status: z.number(),
})
.optional();
const searchSchema = z.object({ const searchSchema = z.object({
results: z results: z
.array( .array(
@@ -444,7 +373,7 @@ const searchSchema = z.object({
name: z.string(), name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: searchMediaInfoSchema, mediaInfo: z.object({}).optional(),
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -452,14 +381,14 @@ const searchSchema = z.object({
title: z.string(), title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: searchMediaInfoSchema, mediaInfo: z.object({}).optional(),
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
mediaType: z.literal("person"), mediaType: z.literal("person"),
name: z.string(), name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(), profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: searchMediaInfoSchema, mediaInfo: z.object({}).optional(),
}), }),
]), ]),
) )
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table"; import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
} }
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => { 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, integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId, mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType, mediaType: innerProps.mediaType,
}); });
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({ const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
onSuccess() { onSuccess() {
actions.closeModal(); actions.closeModal();
showSuccessNotification({ showSuccessNotification({
@@ -33,7 +33,6 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
const isPending = isPendingQuery || isPendingMutation; const isPending = isPendingQuery || isPendingMutation;
const t = useI18n(); const t = useI18n();
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
const columns = useMemo<MRT_ColumnDef<Season>[]>( const columns = useMemo<MRT_ColumnDef<Season>[]>(
() => [ () => [
@@ -45,18 +44,8 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
accessorKey: "episodeCount", accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"), 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({ const table = useTranslatedMantineReactTable({
@@ -67,7 +56,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
enablePagination: false, enablePagination: false,
enableSorting: false, enableSorting: false,
enableSelectAll: true, enableSelectAll: true,
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber), enableRowSelection: true,
mantineTableProps: { mantineTableProps: {
highlightOnHover: false, highlightOnHover: false,
striped: "odd", striped: "odd",
@@ -44,9 +44,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
setQuery(interaction.query); setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") { } else if (interaction.type === "mode") {
if (interaction.query !== undefined) {
setQuery(interaction.query);
}
setMode(interaction.mode); setMode(interaction.mode);
} else if (interaction.type === "children") { } else if (interaction.type === "children") {
setChildrenOptions(interaction); setChildrenOptions(interaction);
@@ -1,28 +1,20 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react"; 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 { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react"; import { IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { OpenMediaRequestSearchOptions } from "../index";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups"; import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
import { import { selectAction, spotlightStore } from "../spotlight-store";
mediaRequestSearchEvent,
mediaRequestSearchScopeAtom,
selectAction,
spotlightActions,
spotlightStore,
} from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; 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 [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n(); const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
// Works as always the same amount of hooks are executed // Works as always the same amount of hooks are executed
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups(); const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroups(); 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 ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
@@ -120,13 +87,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.Search <MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`} placeholder={`${t("search.placeholder")}...`}
ref={inputRef} ref={inputRef}
leftSectionWidth={hasModeCharacter ? 80 : 48} leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
leftSection={ leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%"> <Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%"> <Center w={48} h="100%">
<IconSearch stroke={1.5} /> <IconSearch stroke={1.5} />
</Center> </Center>
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null} {activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group> </Group>
} }
styles={{ styles={{
+2 -17
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store"; import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight"; export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch }; export { openSpotlight };
export { export {
SpotlightProvider, SpotlightProvider,
useRegisterSpotlightContextResults, useRegisterSpotlightContextResults,
@@ -11,18 +11,3 @@ export {
} from "./modes/home/context"; } from "./modes/home/context";
const openSpotlight = spotlightActions.open; 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 };
+1 -1
View File
@@ -12,7 +12,7 @@ const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(), createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(), 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<{ createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"]; useActions: CreateChildrenOptionsProps<any>["useActions"];
@@ -64,36 +64,6 @@ type MediaRequestChildrenProps = {
kind: IntegrationKind; kind: IntegrationKind;
url: string; url: string;
id: 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, searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult, searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type !== "fromIntegration") { if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type"); throw new Error("Invalid search engine type");
} }
@@ -115,7 +87,25 @@ export const useFromIntegrationSearchInteraction = (
) && ) &&
"type" in searchResult "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 { return {
@@ -125,15 +115,13 @@ export const useFromIntegrationSearchInteraction = (
}; };
}; };
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({ const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() { useActions() {
const { openModal } = useModalAction(RequestMediaModal); const { openModal } = useModalAction(RequestMediaModal);
return [ return [
{ {
key: "request", key: "request",
hide: (option) => hide: (option) => option.result.inLibrary,
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) { Component(option) {
const t = useScopedI18n("search.mode.media"); const t = useScopedI18n("search.mode.media");
return ( return (
@@ -19,7 +19,6 @@ import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board"; import { appIntegrationBoardMode } from "../app-integration-board";
import { commandMode } from "../command"; import { commandMode } from "../command";
import { externalMode } from "../external"; import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page"; import { pageMode } from "../page";
import { userGroupMode } from "../user-group"; import { userGroupMode } from "../user-group";
@@ -34,7 +33,7 @@ type QuickLinkOption = {
export const useHomeEmptyGroups = () => { export const useHomeEmptyGroups = () => {
const { data: session } = useSession(); const { data: session } = useSession();
const tPages = useScopedI18n("search.mode.page.group.page.option"); 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")) { if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode); visibleSearchModes.unshift(userGroupMode);
@@ -88,7 +87,7 @@ export const useHomeEmptyGroups = () => {
useInteraction: interaction.link(({ path }) => ({ href: path })), useInteraction: interaction.link(({ path }) => ({ href: path })),
}), }),
createGroup({ createGroup({
keyPath: "modeKey", keyPath: "character",
title: (t) => t("search.mode.help.group.mode.title"), title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => { Component: ({ modeKey, character }) => {
@@ -97,7 +96,7 @@ export const useHomeEmptyGroups = () => {
return ( return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between"> <Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text> <Text>{t("help")}</Text>
{character && <Kbd size="sm">{character}</Kbd>} <Kbd size="sm">{character}</Kbd>
</Group> </Group>
); );
}, },
@@ -20,7 +20,6 @@ type GroupItem = {
name: string; name: string;
description?: string; description?: string;
icon: TablerIcon | string; icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
}; };
@@ -38,7 +37,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
); );
return ( 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} {icon}
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
@@ -92,7 +91,9 @@ const createDefaultSearchEntries = (
query: string, query: string,
t: TranslationFunction, t: TranslationFunction,
): GroupItem[] => { ): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) return []; if (!session?.user && !defaultSearchEngine) {
return [];
}
if (!defaultSearchEngine) { if (!defaultSearchEngine) {
return [ return [
-2
View File
@@ -2,7 +2,6 @@ import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home"; import { homeMode } from "./home";
import { mediaMode } from "./media";
import { pageMode } from "./page"; import { pageMode } from "./page";
import { userGroupMode } from "./user-group"; import { userGroupMode } from "./user-group";
@@ -12,6 +11,5 @@ export const searchModes = [
externalMode, externalMode,
commandMode, commandMode,
pageMode, pageMode,
mediaMode,
homeMode, homeMode,
] as const; ] 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 { clamp } from "@mantine/hooks";
import type { SpotlightStore } from "@mantine/spotlight"; import type { SpotlightStore } from "@mantine/spotlight";
import { createSpotlight } from "@mantine/spotlight"; import { createSpotlight } from "@mantine/spotlight";
import { atom } from "jotai";
export const [spotlightStore, spotlightActions] = createSpotlight(); 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) => { export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index })); store.updateState((state) => ({ ...state, selected: index }));
}; };
+2 -20
View File
@@ -4551,23 +4551,9 @@
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "", "requestMovie": "",
"requestSeries": "", "requestSeries": "",
"openIn": "", "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."
}
}
}
}, },
"external": { "external": {
"help": "", "help": "",
@@ -4843,11 +4829,7 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes", "episodes": "Episodes"
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+2 -20
View File
@@ -4551,23 +4551,9 @@
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "Request movie", "requestMovie": "Request movie",
"requestSeries": "Request series", "requestSeries": "Request series",
"openIn": "Open in {kind}", "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."
}
}
}
}, },
"external": { "external": {
"help": "Use an external search engine", "help": "Use an external search engine",
@@ -4843,11 +4829,7 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes", "episodes": "Episodes"
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+5 -5
View File
@@ -1056,7 +1056,7 @@
} }
}, },
"noResults": { "noResults": {
"title": "" "title": "Nessun media caricato"
} }
}, },
"common": { "common": {
@@ -1088,8 +1088,8 @@
"checkLogs": "Controlla i log per maggiori dettagli", "checkLogs": "Controlla i log per maggiori dettagli",
"tryAgain": "Riprova", "tryAgain": "Riprova",
"loading": "Caricamento", "loading": "Caricamento",
"show": "", "show": "Visualizza",
"hide": "" "hide": "Nascondi"
}, },
"here": "qui", "here": "qui",
"iconPicker": { "iconPicker": {
@@ -1255,7 +1255,7 @@
"label": { "label": {
"create": "Nuova categoria", "create": "Nuova categoria",
"changePosition": "Cambia posizione", "changePosition": "Cambia posizione",
"settings": "" "settings": "Impostazioni"
} }
}, },
"openAllInNewTabs": { "openAllInNewTabs": {
@@ -3459,7 +3459,7 @@
"private": "Questa board è privata" "private": "Questa board è privata"
}, },
"noResults": { "noResults": {
"title": "" "title": "Ancora nessuna board"
}, },
"modal": { "modal": {
"createBoard": { "createBoard": {
+106 -106
View File
@@ -82,7 +82,7 @@
"form": { "form": {
"name": { "name": {
"label": "Gruppens namn", "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" "label": "Visa sökresultat i ny flik"
}, },
"ddgBangs": { "ddgBangs": {
"label": "" "label": "Aktivera DuckDuckGo bangs"
} }
}, },
"error": { "error": {
@@ -252,10 +252,10 @@
"changeDdgBangs": { "changeDdgBangs": {
"notification": { "notification": {
"success": { "success": {
"message": "" "message": "DuckDuckGo bangs inställningen ändrad"
}, },
"error": { "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" "description": "Tillåt medlemmar att addera valfri applikation på sina tavlor"
}, },
"modify-all": { "modify-all": {
"label": "", "label": "Modifiera alla appar",
"description": "" "description": "Tillåt att medlemmar modifierar alla appar"
}, },
"full-all": { "full-all": {
"label": "", "label": "Fullständiga rättigheter till appar",
"description": "" "description": "Tillåt att medlemmar hanterar, använder och raderar alla appar"
} }
} }
}, },
@@ -363,20 +363,20 @@
"title": "Tavlor", "title": "Tavlor",
"item": { "item": {
"create": { "create": {
"label": "", "label": "Skapa tavlor",
"description": "" "description": "Tillåt att medlemmar skapar tavlor"
}, },
"view-all": { "view-all": {
"label": "", "label": "Se alla tavlor",
"description": "" "description": "Tillåt att medlemmar ser alla tavlor"
}, },
"modify-all": { "modify-all": {
"label": "", "label": "Modifiera alla tavlor",
"description": "" "description": "Tillåt att medlemmar modifierar alla tavlor (Detta inkluderar inte åtkomstkontroll eller farozonen)"
}, },
"full-all": { "full-all": {
"label": "", "label": "Fullständiga rättigheter till tavlor",
"description": "" "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" "description": "Tillåter medlemmar att addera valfri integration till sina tavlor"
}, },
"interact-all": { "interact-all": {
"label": "", "label": "Interagera med alla integrationer",
"description": "" "description": "Tillåt att medlemmar interagerar med alla integrationer"
}, },
"full-all": { "full-all": {
"label": "", "label": "Fullständiga rättigheter till integrationer",
"description": "" "description": "Tillåt att medlemmar hanterar, använder och interagerar med alla integrationer"
} }
} }
}, },
"media": { "media": {
"title": "", "title": "Media",
"item": { "item": {
"upload": { "upload": {
"label": "", "label": "Ladda upp media",
"description": "" "description": "Tillåt att medlemmar laddar upp media"
}, },
"view-all": { "view-all": {
"label": "", "label": "Se all media",
"description": "" "description": "Tillåt att medlemmar ser all media"
}, },
"full-all": { "full-all": {
"label": "", "label": "Fullständiga rättigheter till media",
"description": "" "description": "Tillåt att medlemmar hanterar och raderar all media"
} }
} }
}, },
"other": { "other": {
"title": "", "title": "Annat",
"item": { "item": {
"view-logs": { "view-logs": {
"label": "", "label": "Se loggar",
"description": "" "description": "Tillåt att medlemmar ser loggar"
} }
} }
}, },
@@ -435,32 +435,32 @@
"description": "Tillåt medlemmar att skapa sökmotorer" "description": "Tillåt medlemmar att skapa sökmotorer"
}, },
"modify-all": { "modify-all": {
"label": "", "label": "Modifiera alla sökmotorer",
"description": "" "description": "Tillåt att medlemmar modifierar alla sökmotorer"
}, },
"full-all": { "full-all": {
"label": "", "label": "Fullständiga rättigheter till sökmotorer",
"description": "" "description": "Tillåt att medlemmar hanterar och raderar alla sökmotorer"
} }
} }
} }
}, },
"memberNotice": { "memberNotice": {
"mixed": "", "mixed": "Vissa medlemmar är från externa tjänster och kan inte hantera här",
"external": "" "external": "Alla medlemmar är från externa tjänster och kan inte hanteras här"
}, },
"reservedNotice": { "reservedNotice": {
"message": "" "message": "Denna grupp är reserverad för systemet så vissa åtgärder är begränsade. <checkoutDocs></checkoutDocs>"
}, },
"action": { "action": {
"create": { "create": {
"label": "Addera en grupp", "label": "Addera en grupp",
"notification": { "notification": {
"success": { "success": {
"message": "" "message": "Gruppen har skapats"
}, },
"error": { "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}?", "confirm": "Är du säker på att du vill överföra ägarskapet för gruppen {name} till {username}?",
"notification": { "notification": {
"success": { "success": {
"message": "" "message": "Överfört grupp {group} till {user}"
}, },
"error": { "error": {
"message": "Kunde inte överföra ägarskap" "message": "Kunde inte överföra ägarskap"
@@ -501,38 +501,38 @@
"notification": { "notification": {
"success": { "success": {
"title": "Behörigheter sparade", "title": "Behörigheter sparade",
"message": "" "message": "Rättigheterna sparade"
}, },
"error": { "error": {
"title": "", "title": "Rättigheterna ej sparade",
"message": "" "message": "Rättigheterna har inte sparats"
} }
} }
}, },
"update": { "update": {
"notification": { "notification": {
"success": { "success": {
"message": "" "message": "Gruppen {name} har sparats"
}, },
"error": { "error": {
"message": "" "message": "Kunde inte spara gruppen {name}"
} }
} }
}, },
"select": { "select": {
"label": "Välj en grupp", "label": "Välj en grupp",
"notFound": "" "notFound": "Ingen grupp hittad"
}, },
"settings": { "settings": {
"board": { "board": {
"notification": { "notification": {
"success": { "success": {
"title": "Inställningarna sparades", "title": "Inställningarna sparades",
"message": "" "message": "Inställningarna för tavlan har sparats"
}, },
"error": { "error": {
"title": "Kunde inte spara inställningarna", "title": "Kunde inte spara inställningarna",
"message": "" "message": "Kunde inte spara inställningarna för tavlan"
} }
} }
} }
@@ -540,10 +540,10 @@
"changePosition": { "changePosition": {
"notification": { "notification": {
"success": { "success": {
"message": "" "message": "Ordning ändrad"
}, },
"error": { "error": {
"message": "" "message": "Ordningen kunde ändras"
} }
} }
} }
@@ -559,20 +559,20 @@
"list": { "list": {
"title": "Applikationer", "title": "Applikationer",
"noResults": { "noResults": {
"title": "", "title": "Det finns inga appar ännu",
"action": "" "action": "Skapa din första app"
} }
}, },
"create": { "create": {
"title": "Addera applikation", "title": "Addera applikation",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Skapandet lyckades",
"message": "" "message": "Appen skapades framgångsrikt"
}, },
"error": { "error": {
"title": "", "title": "Skapandet misslyckades",
"message": "" "message": "Appen kunde inte skapas"
} }
} }
}, },
@@ -581,11 +581,11 @@
"notification": { "notification": {
"success": { "success": {
"title": "Ändringarna har sparats", "title": "Ändringarna har sparats",
"message": "" "message": "Appen sparades"
}, },
"error": { "error": {
"title": "Misslyckades med att spara ändringarna", "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}?", "message": "Är du säker på att du vill ta bort applikationen {name}?",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Borttagning lyckades",
"message": "" "message": "Appen har tagits bort"
}, },
"error": { "error": {
"title": "", "title": "Borttagning misslyckades",
"message": "" "message": "Kunde inte ta bort appen"
} }
} }
} }
@@ -612,21 +612,21 @@
"label": "Beskrivning" "label": "Beskrivning"
}, },
"url": { "url": {
"label": "" "label": "URL"
}, },
"useDifferentUrlForPing": { "useDifferentUrlForPing": {
"checkbox": { "checkbox": {
"label": "", "label": "Använd annan URL för ping",
"description": "" "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": { "action": {
"select": { "select": {
"label": "", "label": "Välj app",
"notFound": "", "notFound": "Ingen app hittades",
"search": "Sök efter en applikation", "search": "Sök efter en applikation",
"noResults": "", "noResults": "Inga resultat",
"action": "Välj {app}", "action": "Välj {app}",
"title": "Välj en applikation att lägga till på tavlan" "title": "Välj en applikation att lägga till på tavlan"
}, },
@@ -642,56 +642,56 @@
"page": { "page": {
"list": { "list": {
"title": "Integrationer", "title": "Integrationer",
"search": "", "search": "Sök integrationer",
"noResults": { "noResults": {
"title": "" "title": "Det finns inga integrationer ännu"
} }
}, },
"create": { "create": {
"title": "", "title": "Ny {name} integration",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Skapandet lyckades",
"message": "" "message": "Integrationen har skapats"
}, },
"error": { "error": {
"title": "", "title": "Skapandet misslyckades",
"message": "" "message": "Integrationen kunde inte skapas"
} }
}, },
"app": { "app": {
"option": { "option": {
"existing": { "existing": {
"title": "", "title": "Befintlig",
"label": "" "label": "Välj befintlig app"
}, },
"new": { "new": {
"title": "", "title": "Ny",
"url": { "url": {
"label": "", "label": "App URL",
"description": "" "description": "URL:en som appen kommer att öppna när den används från tavlan"
} }
} }
} }
} }
}, },
"edit": { "edit": {
"title": "", "title": "Redigera {name} integration",
"notification": { "notification": {
"success": { "success": {
"title": "Ändringarna har sparats", "title": "Ändringarna har sparats",
"message": "" "message": "Integrationen sparad"
}, },
"error": { "error": {
"title": "", "title": "Kunde inte tillämpa ändringar",
"message": "" "message": "Integrationen kunde inte sparas"
} }
}, },
"app": { "app": {
"action": { "action": {
"add": "", "add": "Länka en app",
"remove": "", "remove": "Ta bort länk",
"select": "" "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}?", "message": "Är du säker på att du vill ta bort integrationen {name}?",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Borttagning lyckades",
"message": "" "message": "Integrationen har tagits bort"
}, },
"error": { "error": {
"title": "", "title": "Borttagning misslyckades",
"message": "" "message": "Kunde inte ta bort integrationen"
} }
} }
} }
@@ -715,44 +715,44 @@
"label": "Namn" "label": "Namn"
}, },
"url": { "url": {
"label": "" "label": "URL"
}, },
"attemptSearchEngineCreation": { "attemptSearchEngineCreation": {
"label": "", "label": "Skapa sökmotor",
"description": "" "description": "Integrationen \"{kind}\" kan inte användas med sökmotorer. Markera detta för att automatiskt konfigurera sökmotorn."
}, },
"app": { "app": {
"sectionTitle": "" "sectionTitle": "Länkad app"
}, },
"createApp": { "createApp": {
"label": "", "label": "Skapa app",
"description": "" "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": { "appHref": {
"placeholder": "" "placeholder": "Egen app-URL"
} }
}, },
"action": { "action": {
"create": "" "create": "Ny integration"
}, },
"testConnection": { "testConnection": {
"action": { "action": {
"create": "", "create": "Testa anslutningen och skapa",
"edit": "" "edit": "Testa anslutningen och skapa"
}, },
"error": { "error": {
"common": { "common": {
"cause": { "cause": {
"title": "" "title": "Orsak med mer detaljer"
} }
}, },
"unknown": { "unknown": {
"title": "", "title": "Okänt fel",
"description": "" "description": "Ett okänt fel inträffade, öppna orsaken nedan för att se mer detaljer"
}, },
"parse": { "parse": {
"title": "", "title": "Parsningsfel",
"description": "" "description": "Svaret kunde inte analyseras. Verifiera att URL pekar på bas-URL för tjänsten."
}, },
"authorization": { "authorization": {
"title": "", "title": "",
@@ -1,32 +1,17 @@
"use client"; "use client";
import { import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
ActionIcon, import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
Anchor,
Avatar,
Badge,
Box,
Card,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api"; import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { MediaRequestStatus } from "@homarr/integrations/types"; import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
@@ -58,7 +43,7 @@ export default function MediaServerWidget({
const newData = filteredData.concat( const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
); );
return newData.toSorted((dataA, dataB) => { return newData.sort((dataA, dataB) => {
if (dataA.status === dataB.status) { if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime(); return dataB.createdAt.getTime() - dataA.createdAt.getTime();
} }
@@ -76,46 +61,25 @@ export default function MediaServerWidget({
if (mediaRequests.length === 0) throw new NoIntegrationDataError(); if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return ( return (
<Box className={classes.searchRoot}> <ScrollArea
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />} className="mediaRequests-list-scrollArea"
<ScrollArea scrollbarSize="md"
className="mediaRequests-list-scrollArea" style={{ pointerEvents: isEditMode ? "none" : undefined }}
scrollbarSize="md" >
style={{ pointerEvents: isEditMode ? "none" : undefined }} <Stack className="mediaRequests-list-list" gap="xs" p="sm">
> {mediaRequests.map((mediaRequest) => (
<Stack className="mediaRequests-list-list" gap="xs" p="sm"> <MediaRequestCard
{mediaRequests.map((mediaRequest) => ( key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
<MediaRequestCard request={mediaRequest}
key={`${mediaRequest.integrationId}-${mediaRequest.id}`} isTiny={width <= 256}
request={mediaRequest} options={options}
isTiny={width <= 256} />
options={options} ))}
/> </Stack>
))} </ScrollArea>
</Stack>
</ScrollArea>
</Box>
); );
} }
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 { interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean; 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"; "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 type { Icon } from "@tabler/icons-react";
import { import {
IconDeviceTv, IconDeviceTv,
@@ -9,7 +9,6 @@ import {
IconMovie, IconMovie,
IconPlayerPlay, IconPlayerPlay,
IconReceipt, IconReceipt,
IconSearch,
IconThumbDown, IconThumbDown,
IconThumbUp, IconThumbUp,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -18,13 +17,11 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types"; import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
const OVERSEERR_COLOR = "#ECB000"; const OVERSEERR_COLOR = "#ECB000";
const JELLYSEERR_COLOR = "#6677CC"; const JELLYSEERR_COLOR = "#6677CC";
@@ -96,104 +93,83 @@ export default function MediaServerWidget({
const isTiny = width < 256; const isTiny = width < 256;
return ( return (
<Box className={searchClasses.searchRoot}> <Stack
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />} className="mediaRequests-stats-layout"
<Stack h="100%"
className="mediaRequests-stats-layout" gap="xs"
h="100%" p="sm"
gap="xs" align="center"
p="sm" justify="space-between"
align="center" style={{ pointerEvents: isEditMode ? "none" : undefined }}
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"}>
<Stack gap={4} w="100%"> {t("titles.stats.main")}
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}> </Text>
{t("titles.stats.main")} <Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
</Text> {data.map((stat) => (
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%"> <Grid.Col
{data.map((stat) => ( className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
<Grid.Col key={stat.name}
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)} span={isTiny ? 6 : 3}
key={stat.name} >
span={isTiny ? 6 : 3} <Tooltip label={t(`titles.stats.${stat.name}`)}>
> <Card p={0} radius={board.itemRadius} className={classes.card}>
<Tooltip label={t(`titles.stats.${stat.name}`)}> <Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
<Card p={0} radius={board.itemRadius} className={classes.card}> <stat.icon className="mediaRequests-stats-stat-icon" size={16} />
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%"> <Text className="mediaRequests-stats-stat-value" size="md">
<stat.icon className="mediaRequests-stats-stat-icon" size={16} /> {stat.number}
<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}
</Text> </Text>
</Group> </Group>
</Card>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}> </Tooltip>
{user.requestCount} </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> </Text>
</Group> </Group>
</Card>
))} <Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
</Stack> {user.requestCount}
</Text>
</Group>
</Card>
))}
</Stack> </Stack>
</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>
);
};
+483 -507
View File
File diff suppressed because it is too large. Load diff
+30 -30
View File
@@ -6,7 +6,7 @@ catalog:
"@auth/core": ^0.41.2 "@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2 "@auth/drizzle-adapter": ^1.11.2
"@ctrl/deluge": ^8.0.0 "@ctrl/deluge": ^8.0.0
"@ctrl/qbittorrent": ^9.12.1 "@ctrl/qbittorrent": ^9.12.2
"@ctrl/transmission": ^7.4.0 "@ctrl/transmission": ^7.4.0
"@dnd-kit/core": ^6.3.1 "@dnd-kit/core": ^6.3.1
"@dnd-kit/modifiers": ^9.0.0 "@dnd-kit/modifiers": ^9.0.0
@@ -20,17 +20,17 @@ catalog:
"@immich/sdk": ^2.7.5 "@immich/sdk": ^2.7.5
"@jellyfin/sdk": ^0.13.0 "@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0 "@kubernetes/client-node": ^1.4.0
"@mantine/charts": ^9.2.1 "@mantine/charts": ^9.2.2
"@mantine/colors-generator": ^9.2.1 "@mantine/colors-generator": ^9.2.2
"@mantine/core": ^9.2.1 "@mantine/core": ^9.2.2
"@mantine/dates": ^9.2.1 "@mantine/dates": ^9.2.2
"@mantine/dropzone": ^9.2.1 "@mantine/dropzone": ^9.2.2
"@mantine/form": ^9.2.1 "@mantine/form": ^9.2.2
"@mantine/hooks": ^9.2.1 "@mantine/hooks": ^9.2.2
"@mantine/modals": ^9.2.1 "@mantine/modals": ^9.2.2
"@mantine/notifications": ^9.2.1 "@mantine/notifications": ^9.2.2
"@mantine/spotlight": ^9.2.1 "@mantine/spotlight": ^9.2.2
"@mantine/tiptap": ^9.2.1 "@mantine/tiptap": ^9.2.2
"@next/eslint-plugin-next": 16.2.6 "@next/eslint-plugin-next": 16.2.6
"@octokit/auth-app": ^8.2.0 "@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0 "@paralleldrive/cuid2": ^3.3.0
@@ -43,12 +43,12 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1 "@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11 "@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0 "@tabler/icons-react": ^3.44.0
"@tanstack/react-query": ^5.100.13 "@tanstack/react-query": ^5.100.14
"@tanstack/react-query-devtools": ^5.100.13 "@tanstack/react-query-devtools": ^5.100.14
"@tanstack/react-query-next-experimental": ^5.100.13 "@tanstack/react-query-next-experimental": ^5.100.14
"@testcontainers/mysql": ^12.0.0 "@testcontainers/mysql": ^12.0.1
"@testcontainers/postgresql": ^12.0.0 "@testcontainers/postgresql": ^12.0.1
"@testcontainers/redis": ^12.0.0 "@testcontainers/redis": ^12.0.1
"@tiptap/extension-color": 3.23.6 "@tiptap/extension-color": 3.23.6
"@tiptap/extension-highlight": 3.23.6 "@tiptap/extension-highlight": 3.23.6
"@tiptap/extension-image": 3.23.6 "@tiptap/extension-image": 3.23.6
@@ -70,7 +70,7 @@ catalog:
"@trpc/react-query": ^11.17.0 "@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0 "@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^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/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0 "@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13 "@types/better-sqlite3": 7.6.13
@@ -109,7 +109,7 @@ catalog:
croner: 10.0.1 croner: 10.0.1
cross-env: ^10.1.0 cross-env: ^10.1.0
crypto-js: ^4.2.0 crypto-js: ^4.2.0
dayjs: ^1.11.20 dayjs: ^1.11.21
deepmerge: 4.3.1 deepmerge: 4.3.1
dns-caching: ^0.2.9 dns-caching: ^0.2.9
dockerode: ^5.0.0 dockerode: ^5.0.0
@@ -124,30 +124,30 @@ catalog:
flag-icons: ^7.5.0 flag-icons: ^7.5.0
glob: ^13.0.6 glob: ^13.0.6
ical.js: ^2.2.1 ical.js: ^2.2.1
ioredis: 5.10.1 ioredis: 5.11.0
isomorphic-dompurify: ^3.14.0 isomorphic-dompurify: ^3.15.0
jotai: ^2.20.0 jotai: ^2.20.0
jsdom: ^29.1.1 jsdom: ^29.1.1
json5: ^2.2.3 json5: ^2.2.3
ldapts: 8.1.8 ldapts: 8.1.8
lodash.clonedeep: ^4.5.0 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 maria2: ^0.4.1
mysql2: 3.22.3 mysql2: 3.22.4
next: 16.2.6 next: 16.2.6
next-auth: 5.0.0-beta.31 next-auth: 5.0.0-beta.31
next-intl: 4.12.0 next-intl: 4.13.0
node-loader: ^2.1.0 node-loader: ^2.1.0
octokit: ^5.0.5 octokit: ^5.0.5
oxfmt: ^0.51.0 oxfmt: ^0.52.0
oxlint: ^1.66.0 oxlint: ^1.67.0
pg: ^8.21.0 pg: ^8.21.0
postcss-preset-mantine: ^1.18.0 postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0 prismjs: ^1.30.0
proxmox-api: 1.1.1 proxmox-api: 1.1.1
react: 19.2.6 react: 19.2.6
react-dom: 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-markdown: ^10.1.0
react-quill-new: ^3.8.3 react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1 react-simple-code-editor: ^0.14.1
@@ -158,12 +158,12 @@ catalog:
superjson: 2.2.6 superjson: 2.2.6
svgson: ^5.3.1 svgson: ^5.3.1
swagger-ui-react: ^5.32.6 swagger-ui-react: ^5.32.6
testcontainers: ^12.0.0 testcontainers: ^12.0.1
timezones-ical-library: ^2.2.0 timezones-ical-library: ^2.2.0
trpc-to-openapi: ^3.3.0 trpc-to-openapi: ^3.3.0
tsdav: ^2.2.2 tsdav: ^2.2.2
tsx: 4.20.4 tsx: 4.20.4
turbo: ^2.9.14 turbo: ^2.9.16
typescript: ^5.9.3 typescript: ^5.9.3
undici: 7.24.6 undici: 7.24.6
use-deep-compare-effect: ^1.8.1 use-deep-compare-effect: ^1.8.1