Compare commits
7
Commits
dev
...
agent/48ba7f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7dae52b7e | ||
|
|
0f3eabfb11 | ||
|
|
b2b22aace0 | ||
|
|
3e856c648f | ||
|
|
8a0c438a7d | ||
|
|
5a697f911a | ||
|
|
d4279a938d |
No files matched your search
@@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
const integrationCategories = integrationDefs[searchParams.kind].category.flat();
|
||||||
|
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))}>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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";
|
||||||
@@ -14,11 +16,9 @@ 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,6 +33,7 @@ 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";
|
||||||
@@ -41,6 +42,7 @@ 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 }) => {
|
||||||
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort(
|
.toSorted(
|
||||||
(integrationA, integrationB) =>
|
(integrationA, integrationB) =>
|
||||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
);
|
);
|
||||||
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort(
|
.toSorted(
|
||||||
(integrationA, integrationB) =>
|
(integrationA, integrationB) =>
|
||||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
);
|
);
|
||||||
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort(
|
.toSorted(
|
||||||
(integrationA, integrationB) =>
|
(integrationA, integrationB) =>
|
||||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
);
|
);
|
||||||
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
url: input.url,
|
url: input.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
|
||||||
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");
|
||||||
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
}),
|
}),
|
||||||
users: userPermissions
|
users: userPermissions
|
||||||
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
user,
|
user,
|
||||||
permission,
|
permission,
|
||||||
}))
|
}))
|
||||||
.sort((permissionA, permissionB) => {
|
.toSorted((permissionA, permissionB) => {
|
||||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||||
}),
|
}),
|
||||||
groups: dbGroupIntegrationPermission
|
groups: dbGroupIntegrationPermission
|
||||||
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
permission,
|
permission,
|
||||||
}))
|
}))
|
||||||
.sort((permissionA, permissionB) => {
|
.toSorted((permissionA, permissionB) => {
|
||||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -618,8 +607,149 @@ 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;
|
||||||
@@ -640,35 +770,6 @@ 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,12 +6,9 @@ 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" });
|
||||||
@@ -148,20 +145,6 @@ 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 } from "@homarr/db/schema";
|
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } 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,6 +59,127 @@ 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();
|
||||||
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
|
|||||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with create integration access should create a new integration when creating search engine", async () => {
|
test("with create integration access should not create a search engine for media request search integrations", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
@@ -243,12 +364,7 @@ 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!.integrationId).toBe(dbIntegration!.id);
|
expect(dbSearchEngine).toBeUndefined();
|
||||||
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,7 +14,9 @@ export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MIN
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
|
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE, {
|
||||||
|
expectedMaximumDurationInMillis: 15_000,
|
||||||
|
}).withCallback(
|
||||||
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
|
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
|
||||||
widgetKinds: ["mediaRequests-requestList"],
|
widgetKinds: ["mediaRequests-requestList"],
|
||||||
getInput: {
|
getInput: {
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ 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,6 +1,10 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import type { RequestInit, Response } from "undici";
|
||||||
|
import { fetch } from "undici";
|
||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { splitToChunksWithNItems } from "@homarr/common";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -24,6 +28,14 @@ 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;
|
||||||
@@ -40,12 +52,43 @@ export class OverseerrIntegration
|
|||||||
extends Integration
|
extends Integration
|
||||||
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
|
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
|
||||||
{
|
{
|
||||||
public async searchAsync(query: string) {
|
private async createFetchClientAsync(): Promise<OverseerrFetchClient> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
|
const agent = await createCertificateAgentAsync(undefined);
|
||||||
headers: {
|
const apiKey = this.getSecretValue("apiKey");
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
|
const requestAsync = async (url: URL, init?: RequestInit) => {
|
||||||
|
return await withTimeoutAsync(
|
||||||
|
(signal) =>
|
||||||
|
fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal,
|
||||||
|
dispatcher: agent,
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": apiKey,
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchAsync: async (url, init) => {
|
||||||
|
const response = await requestAsync(url, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Overseerr request failed: ${response.status} ${response.statusText} (${url.toString()})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
});
|
fetchUncheckedAsync: requestAsync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchAsync(query: string) {
|
||||||
|
const client = await this.createFetchClientAsync();
|
||||||
|
const response = await client.fetchAsync(this.url("/api/v1/search", { query }));
|
||||||
const schemaData = await searchSchema.parseAsync(await response.json());
|
const schemaData = await searchSchema.parseAsync(await response.json());
|
||||||
|
|
||||||
if (!schemaData.results) {
|
if (!schemaData.results) {
|
||||||
@@ -60,17 +103,22 @@ 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 fetchWithTrustedCertificatesAsync(url, {
|
const response = await client.fetchAsync(url);
|
||||||
headers: {
|
const data = await mediaInformationSchema.parseAsync(await response.json());
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
const requestedSeasons = [
|
||||||
},
|
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
|
||||||
});
|
];
|
||||||
return await mediaInformationSchema.parseAsync(await response.json());
|
const { mediaInfo: _strip, ...rest } = data;
|
||||||
|
return { ...rest, requestedSeasons };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,8 +128,9 @@ 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 fetchWithTrustedCertificatesAsync(url, {
|
const response = await client.fetchUncheckedAsync(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mediaType,
|
mediaType,
|
||||||
@@ -89,7 +138,6 @@ export class OverseerrIntegration
|
|||||||
seasons,
|
seasons,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -115,25 +163,16 @@ export class OverseerrIntegration
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
||||||
const pendingRequests = await fetchWithTrustedCertificatesAsync(
|
const client = await this.createFetchClientAsync();
|
||||||
this.url("/api/v1/request", { take: 20, filter: "pending" }),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
|
const [pendingResponse, allResponse] = await Promise.all([
|
||||||
headers: {
|
client.fetchAsync(this.url("/api/v1/request", { take: 20, filter: "pending" })),
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
client.fetchAsync(this.url("/api/v1/request", { take: 20 })),
|
||||||
},
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
|
const pendingResults = (await getRequestsSchema.parseAsync(await pendingResponse.json())).results;
|
||||||
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
|
const allResults = (await getRequestsSchema.parseAsync(await allResponse.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) {
|
||||||
@@ -142,37 +181,53 @@ 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 Promise.all([]);
|
else return [];
|
||||||
|
|
||||||
return await Promise.all(
|
const uniqueKeys = [...new Set(requests.map((request) => `${request.type}:${request.media.tmdbId}`))];
|
||||||
requests.map(async (request): Promise<MediaRequest> => {
|
const informationByKey = new Map<string, MediaInformation>();
|
||||||
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
|
||||||
|
|
||||||
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
|
for (const batch of splitToChunksWithNItems(uniqueKeys, DETAIL_FETCH_BATCH_SIZE)) {
|
||||||
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
|
const batchResults = await Promise.all(
|
||||||
|
batch.map(async (key) => {
|
||||||
|
const separatorIndex = key.indexOf(":");
|
||||||
|
const type = key.slice(0, separatorIndex) as MediaRequest["type"];
|
||||||
|
const tmdbId = Number(key.slice(separatorIndex + 1));
|
||||||
|
const information = await this.getItemInformationAsync(client, type, tmdbId);
|
||||||
|
return [key, information] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
for (const [key, information] of batchResults) {
|
||||||
id: request.id,
|
informationByKey.set(key, information);
|
||||||
name: information.name,
|
}
|
||||||
status: this.mapRequestStatus(request.status),
|
}
|
||||||
availability: this.mapAvailability(request.media.status, inProgress),
|
|
||||||
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
return requests.map((request): MediaRequest => {
|
||||||
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
const information = informationByKey.get(`${request.type}:${request.media.tmdbId}`)!;
|
||||||
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
|
|
||||||
type: request.type,
|
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
|
||||||
createdAt: request.createdAt,
|
|
||||||
airDate: new Date(information.airDate),
|
return {
|
||||||
requestedBy: request.requestedBy
|
id: request.id,
|
||||||
? ({
|
name: information.name,
|
||||||
...request.requestedBy,
|
status: this.mapRequestStatus(request.status),
|
||||||
displayName: request.requestedBy.displayName,
|
availability: this.mapAvailability(request.media.status, inProgress),
|
||||||
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
|
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
||||||
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
||||||
} satisfies Omit<RequestUser, "requestCount">)
|
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
|
||||||
: undefined,
|
type: request.type,
|
||||||
};
|
createdAt: request.createdAt,
|
||||||
}),
|
airDate: new Date(information.airDate),
|
||||||
);
|
requestedBy: request.requestedBy
|
||||||
|
? ({
|
||||||
|
...request.requestedBy,
|
||||||
|
displayName: request.requestedBy.displayName,
|
||||||
|
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
|
||||||
|
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
||||||
|
} satisfies Omit<RequestUser, "requestCount">)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||||
@@ -212,20 +267,14 @@ export class OverseerrIntegration
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getStatsAsync(): Promise<RequestStats> {
|
public async getStatsAsync(): Promise<RequestStats> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
|
const client = await this.createFetchClientAsync();
|
||||||
headers: {
|
const response = await client.fetchAsync(this.url("/api/v1/request/count"));
|
||||||
"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 response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
|
const client = await this.createFetchClientAsync();
|
||||||
headers: {
|
const response = await client.fetchAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }));
|
||||||
"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 {
|
||||||
@@ -238,75 +287,58 @@ 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 });
|
||||||
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
const client = await this.createFetchClientAsync();
|
||||||
method: "POST",
|
await client
|
||||||
headers: {
|
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
method: "POST",
|
||||||
},
|
})
|
||||||
}).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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
|
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
|
||||||
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(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
private async getItemInformationAsync(
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
|
client: OverseerrFetchClient,
|
||||||
headers: {
|
type: MediaRequest["type"],
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
id: number,
|
||||||
},
|
): Promise<MediaInformation> {
|
||||||
});
|
const response = await client.fetchAsync(this.url(`/api/v1/${type}/${id}`));
|
||||||
|
const rawData = (await response.json()) as TvInformation | MovieInformation;
|
||||||
if (type === "tv") {
|
return mediaInformationFromResponse[type](rawData as never);
|
||||||
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) {
|
||||||
@@ -341,6 +373,37 @@ 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(),
|
||||||
@@ -355,14 +418,22 @@ 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(
|
||||||
@@ -373,7 +444,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: z.object({}).optional(),
|
mediaInfo: searchMediaInfoSchema,
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -381,14 +452,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: z.object({}).optional(),
|
mediaInfo: searchMediaInfoSchema,
|
||||||
}),
|
}),
|
||||||
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: z.object({}).optional(),
|
mediaInfo: searchMediaInfoSchema,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef, MRT_Row } 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.searchEngine.getMediaRequestOptions.useQuery({
|
const { data, isPending: isPendingQuery } = clientApi.integration.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.searchEngine.requestMedia.useMutation({
|
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
@@ -33,6 +33,7 @@ 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>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -44,8 +45,18 @@ 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({
|
||||||
@@ -56,7 +67,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
|||||||
enablePagination: false,
|
enablePagination: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableSelectAll: true,
|
enableSelectAll: true,
|
||||||
enableRowSelection: true,
|
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
|
||||||
mantineTableProps: {
|
mantineTableProps: {
|
||||||
highlightOnHover: false,
|
highlightOnHover: false,
|
||||||
striped: "odd",
|
striped: "odd",
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ 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,20 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, 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 { selectAction, spotlightStore } from "../spotlight-store";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -49,10 +57,35 @@ 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
|
||||||
@@ -87,13 +120,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
|||||||
<MantineSpotlight.Search
|
<MantineSpotlight.Search
|
||||||
placeholder={`${t("search.placeholder")}...`}
|
placeholder={`${t("search.placeholder")}...`}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
|
leftSectionWidth={hasModeCharacter ? 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>
|
||||||
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { spotlightActions } from "./spotlight-store";
|
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
|
||||||
|
|
||||||
export { Spotlight } from "./components/spotlight";
|
export { Spotlight } from "./components/spotlight";
|
||||||
export { openSpotlight };
|
export { openSpotlight, openMediaRequestSearch };
|
||||||
export {
|
export {
|
||||||
SpotlightProvider,
|
SpotlightProvider,
|
||||||
useRegisterSpotlightContextResults,
|
useRegisterSpotlightContextResults,
|
||||||
@@ -11,3 +11,18 @@ 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 };
|
||||||
@@ -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"] }>(),
|
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||||
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,6 +64,36 @@ 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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,8 +101,6 @@ 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");
|
||||||
}
|
}
|
||||||
@@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = (
|
|||||||
) &&
|
) &&
|
||||||
"type" in searchResult
|
"type" in searchResult
|
||||||
) {
|
) {
|
||||||
const type = searchResult.type;
|
return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
|
||||||
if (type === "person") {
|
|
||||||
return {
|
|
||||||
type: "link",
|
|
||||||
href: searchResult.link,
|
|
||||||
newTab: openSearchInNewTab,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "children",
|
|
||||||
...mediaRequestsChildrenOptions({
|
|
||||||
result: {
|
|
||||||
...searchResult,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
integration: searchEngine.integration,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,13 +125,15 @@ export const useFromIntegrationSearchInteraction = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||||
useActions() {
|
useActions() {
|
||||||
const { openModal } = useModalAction(RequestMediaModal);
|
const { openModal } = useModalAction(RequestMediaModal);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "request",
|
key: "request",
|
||||||
hide: (option) => option.result.inLibrary,
|
hide: (option) =>
|
||||||
|
(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,6 +19,7 @@ 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";
|
||||||
|
|
||||||
@@ -33,7 +34,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, commandMode, pageMode];
|
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
|
||||||
|
|
||||||
if (session?.user.permissions.includes("admin")) {
|
if (session?.user.permissions.includes("admin")) {
|
||||||
visibleSearchModes.unshift(userGroupMode);
|
visibleSearchModes.unshift(userGroupMode);
|
||||||
@@ -87,7 +88,7 @@ export const useHomeEmptyGroups = () => {
|
|||||||
useInteraction: interaction.link(({ path }) => ({ href: path })),
|
useInteraction: interaction.link(({ path }) => ({ href: path })),
|
||||||
}),
|
}),
|
||||||
createGroup({
|
createGroup({
|
||||||
keyPath: "character",
|
keyPath: "modeKey",
|
||||||
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 }) => {
|
||||||
@@ -96,7 +97,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>
|
||||||
<Kbd size="sm">{character}</Kbd>
|
{character && <Kbd size="sm">{character}</Kbd>}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
|
||||||
{icon}
|
{icon}
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text>{item.name}</Text>
|
<Text>{item.name}</Text>
|
||||||
@@ -91,9 +92,7 @@ const createDefaultSearchEntries = (
|
|||||||
query: string,
|
query: string,
|
||||||
t: TranslationFunction,
|
t: TranslationFunction,
|
||||||
): GroupItem[] => {
|
): GroupItem[] => {
|
||||||
if (!session?.user && !defaultSearchEngine) {
|
if (!session?.user && !defaultSearchEngine) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!defaultSearchEngine) {
|
if (!defaultSearchEngine) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
|
|
||||||
@@ -11,5 +12,6 @@ export const searchModes = [
|
|||||||
externalMode,
|
externalMode,
|
||||||
commandMode,
|
commandMode,
|
||||||
pageMode,
|
pageMode,
|
||||||
|
mediaMode,
|
||||||
homeMode,
|
homeMode,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { SearchMode } from "../../lib/mode";
|
||||||
|
import { mediaRequestSearchGroup } from "./media-request-search-group";
|
||||||
|
|
||||||
|
export const mediaMode = {
|
||||||
|
modeKey: "media",
|
||||||
|
character: undefined,
|
||||||
|
groups: [mediaRequestSearchGroup],
|
||||||
|
} satisfies SearchMode;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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,9 +3,17 @@
|
|||||||
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 }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4551,9 +4551,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": "",
|
||||||
@@ -4829,7 +4843,11 @@
|
|||||||
"table": {
|
"table": {
|
||||||
"header": {
|
"header": {
|
||||||
"season": "Season",
|
"season": "Season",
|
||||||
"episodes": "Episodes"
|
"episodes": "Episodes",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"requested": "Requested"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
|
|||||||
@@ -4551,9 +4551,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -4829,7 +4843,11 @@
|
|||||||
"table": {
|
"table": {
|
||||||
"header": {
|
"header": {
|
||||||
"season": "Season",
|
"season": "Season",
|
||||||
"episodes": "Episodes"
|
"episodes": "Episodes",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"requested": "Requested"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
import {
|
||||||
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
ActionIcon,
|
||||||
|
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,
|
||||||
@@ -43,7 +58,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.sort((dataA, dataB) => {
|
return newData.toSorted((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();
|
||||||
}
|
}
|
||||||
@@ -61,25 +76,46 @@ export default function MediaServerWidget({
|
|||||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<Box className={classes.searchRoot}>
|
||||||
className="mediaRequests-list-scrollArea"
|
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||||
scrollbarSize="md"
|
<ScrollArea
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
className="mediaRequests-list-scrollArea"
|
||||||
>
|
scrollbarSize="md"
|
||||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
{mediaRequests.map((mediaRequest) => (
|
>
|
||||||
<MediaRequestCard
|
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
{mediaRequests.map((mediaRequest) => (
|
||||||
request={mediaRequest}
|
<MediaRequestCard
|
||||||
isTiny={width <= 256}
|
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||||
options={options}
|
request={mediaRequest}
|
||||||
/>
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.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 { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Avatar, Box, 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,6 +9,7 @@ import {
|
|||||||
IconMovie,
|
IconMovie,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
IconReceipt,
|
IconReceipt,
|
||||||
|
IconSearch,
|
||||||
IconThumbDown,
|
IconThumbDown,
|
||||||
IconThumbUp,
|
IconThumbUp,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -17,11 +18,13 @@ 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";
|
||||||
@@ -93,83 +96,104 @@ export default function MediaServerWidget({
|
|||||||
const isTiny = width < 256;
|
const isTiny = width < 256;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Box className={searchClasses.searchRoot}>
|
||||||
className="mediaRequests-stats-layout"
|
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||||
h="100%"
|
<Stack
|
||||||
gap="xs"
|
className="mediaRequests-stats-layout"
|
||||||
p="sm"
|
h="100%"
|
||||||
align="center"
|
gap="xs"
|
||||||
justify="space-between"
|
p="sm"
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
align="center"
|
||||||
>
|
justify="space-between"
|
||||||
<Stack gap={4} w="100%">
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
>
|
||||||
{t("titles.stats.main")}
|
<Stack gap={4} w="100%">
|
||||||
</Text>
|
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
{t("titles.stats.main")}
|
||||||
{data.map((stat) => (
|
</Text>
|
||||||
<Grid.Col
|
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
{data.map((stat) => (
|
||||||
key={stat.name}
|
<Grid.Col
|
||||||
span={isTiny ? 6 : 3}
|
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||||
>
|
key={stat.name}
|
||||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
span={isTiny ? 6 : 3}
|
||||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
>
|
||||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||||
{stat.number}
|
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||||
|
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||||
|
{stat.number}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={4} w="100%">
|
||||||
|
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||||
|
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||||
|
</Text>
|
||||||
|
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||||
|
{requestStats.users.slice(0, 10).map((user) => (
|
||||||
|
<Card
|
||||||
|
component="a"
|
||||||
|
href={user.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={combineClasses(
|
||||||
|
"mediaRequests-stats-users-user-wrapper",
|
||||||
|
`mediaRequests-stats-users-user-${user.id}`,
|
||||||
|
classes.card,
|
||||||
|
)}
|
||||||
|
key={user.id}
|
||||||
|
p="xs"
|
||||||
|
radius={board.itemRadius}
|
||||||
|
>
|
||||||
|
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||||
|
<Group gap={4}>
|
||||||
|
<Tooltip label={user.integration.name}>
|
||||||
|
<Avatar
|
||||||
|
className="mediaRequests-stats-users-user-avatar"
|
||||||
|
size={20}
|
||||||
|
src={user.avatar}
|
||||||
|
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||||
|
{user.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
|
||||||
</Tooltip>
|
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||||
</Grid.Col>
|
{user.requestCount}
|
||||||
))}
|
|
||||||
</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}>
|
))}
|
||||||
{user.requestCount}
|
</Stack>
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in new issue
Block a user