chore(release): automatic release v0.1.0
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,4 +54,5 @@ yarn-error.log*
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
apps/tasks/tasks.cjs
|
apps/tasks/tasks.cjs
|
||||||
apps/websocket/wssServer.cjs
|
apps/websocket/wssServer.cjs
|
||||||
|
apps/nextjs/.million/
|
||||||
|
|||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -2,6 +2,7 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"yoavbls.pretty-ts-errors"
|
"yoavbls.pretty-ts-errors",
|
||||||
|
"million.million-lint"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -12,10 +12,13 @@
|
|||||||
"cqmin",
|
"cqmin",
|
||||||
"homarr",
|
"homarr",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
|
"mantine",
|
||||||
|
"overseerr",
|
||||||
|
"Sonarr",
|
||||||
"superjson",
|
"superjson",
|
||||||
|
"tabler",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami",
|
"Umami"
|
||||||
"Sonarr"
|
|
||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env.mjs";
|
||||||
|
|
||||||
|
import MillionLint from "@million/lint";
|
||||||
|
|
||||||
import "./src/env.mjs";
|
import "./src/env.mjs";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
@@ -28,4 +31,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||||
|
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -27,22 +27,23 @@
|
|||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/spotlight": "workspace:^0.1.0",
|
"@homarr/spotlight": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.12.1",
|
"@mantine/colors-generator": "^7.12.2",
|
||||||
"@mantine/core": "^7.12.1",
|
"@mantine/core": "^7.12.2",
|
||||||
"@mantine/hooks": "^7.12.1",
|
"@mantine/hooks": "^7.12.2",
|
||||||
"@mantine/modals": "^7.12.1",
|
"@mantine/modals": "^7.12.2",
|
||||||
"@mantine/tiptap": "^7.12.1",
|
"@mantine/tiptap": "^7.12.2",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@million/lint": "1.0.0-rc.84",
|
||||||
"@t3-oss/env-nextjs": "^0.11.0",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tanstack/react-query": "^5.52.1",
|
"@tanstack/react-query": "^5.53.1",
|
||||||
"@tanstack/react-query-devtools": "^5.52.1",
|
"@tanstack/react-query-devtools": "^5.53.1",
|
||||||
"@tanstack/react-query-next-experimental": "5.52.1",
|
"@tanstack/react-query-next-experimental": "5.53.1",
|
||||||
"@tabler/icons-react": "^3.12.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.9.3",
|
"jotai": "^2.9.3",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.7",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.77.8",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
|
"swagger-ui-react": "^5.17.14",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,12 +76,13 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.16.1",
|
"@types/node": "^20.16.2",
|
||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"node-loader": "^2.0.0",
|
"node-loader": "^2.0.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.api"),
|
||||||
|
icon: IconPlug,
|
||||||
|
href: "/manage/tools/api",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
|
|||||||
28
apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
Normal file
28
apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||||
|
import "./swagger-ui-dark.css";
|
||||||
|
import "./swagger-ui-overrides.css";
|
||||||
|
import "./swagger-ui.css";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import SwaggerUI from "swagger-ui-react";
|
||||||
|
|
||||||
|
import { openApiDocument } from "@homarr/api";
|
||||||
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: createMetaTitle(t("metaTitle")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiPage() {
|
||||||
|
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
||||||
|
|
||||||
|
return <SwaggerUI spec={document} />;
|
||||||
|
}
|
||||||
1711
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css
Normal file
1711
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
.swagger-ui .info {
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
9296
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css
Normal file
9296
apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css
Normal file
File diff suppressed because it is too large
Load Diff
14
apps/nextjs/src/app/api/[...trpc]/route.ts
Normal file
14
apps/nextjs/src/app/api/[...trpc]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
|
||||||
|
|
||||||
|
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||||
|
|
||||||
|
const handler = (req: Request) => {
|
||||||
|
return createOpenApiFetchHandler({
|
||||||
|
req,
|
||||||
|
endpoint: "/",
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () => createTRPCContext({ session: null, headers: req.headers }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
8
apps/nextjs/src/app/api/openapi/route.ts
Normal file
8
apps/nextjs/src/app/api/openapi/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { openApiDocument } from "@homarr/api";
|
||||||
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
|
|
||||||
|
export function GET(request: Request) {
|
||||||
|
return NextResponse.json(openApiDocument(extractBaseUrlFromHeaders(request.headers)));
|
||||||
|
}
|
||||||
@@ -80,8 +80,16 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
isDynamic: section.kind === "dynamic",
|
isDynamic: section.kind === "dynamic",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const itemRefKeys = Object.keys(itemRefs.current);
|
||||||
// define items in itemRefs for easy access and reference to items
|
// define items in itemRefs for easy access and reference to items
|
||||||
if (Object.keys(itemRefs.current).length !== itemIds.length) {
|
if (itemRefKeys.length !== itemIds.length) {
|
||||||
|
// Remove items that are not in the itemIds
|
||||||
|
// Otherwise when an item is removed and then another item is added, this foreach below will not run.
|
||||||
|
itemRefKeys.forEach((id) => {
|
||||||
|
if (!itemIds.includes(id)) {
|
||||||
|
delete itemRefs.current[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
itemIds.forEach((id) => {
|
itemIds.forEach((id) => {
|
||||||
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
|
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,9 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gridstack-empty-wrapper {
|
/**
|
||||||
height: 0px;
|
* Hide empty wrapper (class is used when no items are inside and not in edit mode)
|
||||||
min-height: 0px !important;
|
*/
|
||||||
|
.grid-stack-empty-wrapper {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^20.16.1",
|
"@types/node": "^20.16.2",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"tsx": "4.13.3",
|
"tsx": "4.13.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -4,7 +4,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.17.0"
|
"node": ">=20.17.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.8.0",
|
"packageManager": "pnpm@9.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"clean": "git clean -xdf node_modules",
|
"clean": "git clean -xdf node_modules",
|
||||||
@@ -30,18 +30,23 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@turbo/gen": "^2.0.14",
|
"@turbo/gen": "^2.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
"@vitest/ui": "^2.0.5",
|
"@vitest/ui": "^2.0.5",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^24.1.1",
|
"jsdom": "^25.0.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"testcontainers": "^10.11.0",
|
"testcontainers": "^10.12.0",
|
||||||
"turbo": "^2.0.14",
|
"turbo": "^2.1.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite-tsconfig-paths": "^5.0.1",
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config",
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"dockerode": "^4.0.2",
|
"dockerode": "^4.0.2",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"next": "^14.2.6",
|
"trpc-swagger": "^1.2.6",
|
||||||
|
"next": "^14.2.7",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/dockerode": "^3.3.31",
|
"@types/dockerode": "^3.3.31",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
import { openApiDocument } from "./open-api";
|
||||||
import type { AppRouter } from "./root";
|
import type { AppRouter } from "./root";
|
||||||
import { appRouter } from "./root";
|
import { appRouter } from "./root";
|
||||||
import { createCallerFactory, createTRPCContext } from "./trpc";
|
import { createCallerFactory, createTRPCContext } from "./trpc";
|
||||||
@@ -29,5 +30,5 @@ type RouterInputs = inferRouterInputs<AppRouter>;
|
|||||||
**/
|
**/
|
||||||
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||||
|
|
||||||
export { createTRPCContext, appRouter, createCaller };
|
export { createTRPCContext, appRouter, createCaller, openApiDocument };
|
||||||
export type { AppRouter, RouterInputs, RouterOutputs };
|
export type { AppRouter, RouterInputs, RouterOutputs };
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
if (offset !== 0) {
|
if (offset !== 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
|
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.join(",")}])`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ export const createManyIntegrationOfOneItemMiddleware = <TKind extends Integrati
|
|||||||
if (dbIntegrationWithItem.length === 0) {
|
if (dbIntegrationWithItem.length === 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Integration for item was not found",
|
message: "Integrations for item were not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
packages/api/src/open-api.ts
Normal file
11
packages/api/src/open-api.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { generateOpenApiDocument } from "trpc-swagger";
|
||||||
|
|
||||||
|
import { appRouter } from "./root";
|
||||||
|
|
||||||
|
export const openApiDocument = (base: string) =>
|
||||||
|
generateOpenApiDocument(appRouter, {
|
||||||
|
title: "Homarr API documentation",
|
||||||
|
version: "1.0.0",
|
||||||
|
baseUrl: base,
|
||||||
|
docsUrl: "https://homarr.dev",
|
||||||
|
});
|
||||||
@@ -48,6 +48,7 @@ export const testConnectionAsync = async (
|
|||||||
return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
|
return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error - For now we expect an error here as not all integerations have been implemented
|
||||||
const integrationInstance = integrationCreatorByKind(integration.kind, {
|
const integrationInstance = integrationCreatorByKind(integration.kind, {
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
name: integration.name,
|
name: integration.name,
|
||||||
|
|||||||
@@ -69,12 +69,16 @@ export const userRouter = createTRPCRouter({
|
|||||||
// Delete invite as it's used
|
// Delete invite as it's used
|
||||||
await ctx.db.delete(invites).where(inviteWhere);
|
await ctx.db.delete(invites).where(inviteWhere);
|
||||||
}),
|
}),
|
||||||
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
|
create: publicProcedure
|
||||||
throwIfCredentialsDisabled();
|
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"] } })
|
||||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
.input(validation.user.create)
|
||||||
|
.output(z.void())
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
throwIfCredentialsDisabled();
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||||
|
|
||||||
await createUserAsync(ctx.db, input);
|
await createUserAsync(ctx.db, input);
|
||||||
}),
|
}),
|
||||||
setProfileImage: protectedProcedure
|
setProfileImage: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -126,20 +130,33 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, input.userId));
|
.where(eq(users.id, input.userId));
|
||||||
}),
|
}),
|
||||||
getAll: publicProcedure.query(async ({ ctx }) => {
|
getAll: publicProcedure
|
||||||
return await ctx.db.query.users.findMany({
|
.input(z.void())
|
||||||
columns: {
|
.output(
|
||||||
id: true,
|
z.array(
|
||||||
name: true,
|
z.object({
|
||||||
email: true,
|
id: z.string(),
|
||||||
emailVerified: true,
|
name: z.string().nullable(),
|
||||||
image: true,
|
email: z.string().nullable(),
|
||||||
provider: true,
|
emailVerified: z.date().nullable(),
|
||||||
},
|
image: z.string().nullable(),
|
||||||
});
|
}),
|
||||||
}),
|
),
|
||||||
selectable: publicProcedure.query(async ({ ctx }) => {
|
)
|
||||||
return await ctx.db.query.users.findMany({
|
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"] } })
|
||||||
|
.query(({ ctx }) => {
|
||||||
|
return ctx.db.query.users.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
selectable: publicProcedure.query(({ ctx }) => {
|
||||||
|
return ctx.db.query.users.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
|
|||||||
import { appRouter } from "./app";
|
import { appRouter } from "./app";
|
||||||
import { calendarRouter } from "./calendar";
|
import { calendarRouter } from "./calendar";
|
||||||
import { dnsHoleRouter } from "./dns-hole";
|
import { dnsHoleRouter } from "./dns-hole";
|
||||||
|
import { mediaRequestsRouter } from "./media-requests";
|
||||||
import { mediaServerRouter } from "./media-server";
|
import { mediaServerRouter } from "./media-server";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
import { rssFeedRouter } from "./rssFeed";
|
import { rssFeedRouter } from "./rssFeed";
|
||||||
@@ -16,5 +17,6 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
smartHome: smartHomeRouter,
|
smartHome: smartHomeRouter,
|
||||||
mediaServer: mediaServerRouter,
|
mediaServer: mediaServerRouter,
|
||||||
calendar: calendarRouter,
|
calendar: calendarRouter,
|
||||||
|
mediaRequests: mediaRequestsRouter,
|
||||||
rssFeed: rssFeedRouter,
|
rssFeed: rssFeedRouter,
|
||||||
});
|
});
|
||||||
|
|||||||
48
packages/api/src/router/widgets/media-requests.ts
Normal file
48
packages/api/src/router/widgets/media-requests.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
|
||||||
|
import { integrationCreatorByKind } from "@homarr/integrations";
|
||||||
|
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createManyIntegrationOfOneItemMiddleware,
|
||||||
|
createOneIntegrationMiddleware,
|
||||||
|
} from "../../middlewares/integration";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
export const mediaRequestsRouter = createTRPCRouter({
|
||||||
|
getLatestRequests: publicProcedure
|
||||||
|
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "overseerr", "jellyseerr"))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await Promise.all(
|
||||||
|
input.integrationIds.map(async (integrationId) => {
|
||||||
|
const channel = createItemAndIntegrationChannel<MediaRequestList>("mediaRequests-requestList", integrationId);
|
||||||
|
return await channel.getAsync();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
getStats: publicProcedure
|
||||||
|
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "overseerr", "jellyseerr"))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await Promise.all(
|
||||||
|
input.integrationIds.map(async (integrationId) => {
|
||||||
|
const channel = createItemAndIntegrationChannel<MediaRequestStats>(
|
||||||
|
"mediaRequests-requestStats",
|
||||||
|
integrationId,
|
||||||
|
);
|
||||||
|
return await channel.getAsync();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
answerRequest: protectedProcedure
|
||||||
|
.unstable_concat(createOneIntegrationMiddleware("interact", "overseerr", "jellyseerr"))
|
||||||
|
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const integration = integrationCreatorByKind(ctx.integration.kind, ctx.integration);
|
||||||
|
|
||||||
|
if (input.answer === "approve") {
|
||||||
|
await integration.approveRequestAsync(input.requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await integration.declineRequestAsync(input.requestId);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import type { OpenApiMeta } from "trpc-swagger";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { FlattenError } from "@homarr/common";
|
import { FlattenError } from "@homarr/common";
|
||||||
@@ -46,17 +47,20 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
|||||||
* This is where the trpc api is initialized, connecting the context and
|
* This is where the trpc api is initialized, connecting the context and
|
||||||
* transformer
|
* transformer
|
||||||
*/
|
*/
|
||||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
const t = initTRPC
|
||||||
transformer: superjson,
|
.context<typeof createTRPCContext>()
|
||||||
errorFormatter: ({ shape, error }) => ({
|
.meta<OpenApiMeta>()
|
||||||
...shape,
|
.create({
|
||||||
data: {
|
transformer: superjson,
|
||||||
...shape.data,
|
errorFormatter: ({ shape, error }) => ({
|
||||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
...shape,
|
||||||
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
|
data: {
|
||||||
},
|
...shape.data,
|
||||||
}),
|
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||||
});
|
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a server-side caller
|
* Create a server-side caller
|
||||||
|
|||||||
@@ -29,11 +29,11 @@
|
|||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@auth/core": "^0.34.2",
|
"@auth/core": "^0.34.2",
|
||||||
"@auth/drizzle-adapter": "^1.4.2",
|
"@auth/drizzle-adapter": "^1.4.2",
|
||||||
"@t3-oss/env-nextjs": "^0.11.0",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "7.1.0",
|
"ldapts": "7.1.1",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.7",
|
||||||
"next-auth": "5.0.0-beta.20",
|
"next-auth": "5.0.0-beta.20",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/cookies": "0.9.0",
|
"@types/cookies": "0.9.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"tldts": "^6.1.41"
|
"tldts": "^6.1.41"
|
||||||
},
|
},
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics";
|
|||||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||||
|
import { mediaRequestsJob } from "./jobs/integrations/media-requests";
|
||||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import type { RssFeed } from "./jobs/rss-feeds";
|
import type { RssFeed } from "./jobs/rss-feeds";
|
||||||
@@ -15,6 +16,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
smartHomeEntityState: smartHomeEntityStateJob,
|
smartHomeEntityState: smartHomeEntityStateJob,
|
||||||
mediaServer: mediaServerJob,
|
mediaServer: mediaServerJob,
|
||||||
mediaOrganizer: mediaOrganizerJob,
|
mediaOrganizer: mediaOrganizerJob,
|
||||||
|
mediaRequests: mediaRequestsJob,
|
||||||
rssFeeds: rssFeedsJob,
|
rssFeeds: rssFeedsJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
51
packages/cron-jobs/src/jobs/integrations/media-requests.ts
Normal file
51
packages/cron-jobs/src/jobs/integrations/media-requests.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { decryptSecret } from "@homarr/common";
|
||||||
|
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||||
|
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
|
||||||
|
import { integrationCreatorByKind } from "@homarr/integrations";
|
||||||
|
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||||
|
|
||||||
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
|
export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => {
|
||||||
|
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||||
|
kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const itemForIntegration of itemsForIntegration) {
|
||||||
|
for (const { integration, integrationId } of itemForIntegration.integrations) {
|
||||||
|
const integrationWithSecrets = {
|
||||||
|
...integration,
|
||||||
|
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||||
|
...secret,
|
||||||
|
value: decryptSecret(secret.value),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestsIntegration = integrationCreatorByKind(integration.kind, integrationWithSecrets);
|
||||||
|
|
||||||
|
const mediaRequests = await requestsIntegration.getRequestsAsync();
|
||||||
|
const requestsStats = await requestsIntegration.getStatsAsync();
|
||||||
|
const requestsUsers = await requestsIntegration.getUsersAsync();
|
||||||
|
const requestListChannel = createItemAndIntegrationChannel<MediaRequestList>(
|
||||||
|
"mediaRequests-requestList",
|
||||||
|
integrationId,
|
||||||
|
);
|
||||||
|
await requestListChannel.publishAndUpdateLastStateAsync({
|
||||||
|
integration: { id: integration.id },
|
||||||
|
medias: mediaRequests,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
|
||||||
|
"mediaRequests-requestStats",
|
||||||
|
integrationId,
|
||||||
|
);
|
||||||
|
await requestStatsChannel.publishAndUpdateLastStateAsync({
|
||||||
|
integration: { kind: integration.kind, name: integration.name },
|
||||||
|
stats: requestsStats,
|
||||||
|
users: requestsUsers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -15,18 +15,20 @@ export const pingJob = createCronJob("ping", EVERY_MINUTE, {
|
|||||||
}).withCallback(async () => {
|
}).withCallback(async () => {
|
||||||
const urls = await pingUrlChannel.getAllAsync();
|
const urls = await pingUrlChannel.getAllAsync();
|
||||||
|
|
||||||
for (const url of new Set(urls)) {
|
await Promise.allSettled([...new Set(urls)].map(pingAsync));
|
||||||
const pingResult = await sendPingRequestAsync(url);
|
|
||||||
|
|
||||||
if ("statusCode" in pingResult) {
|
|
||||||
logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`);
|
|
||||||
} else {
|
|
||||||
logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pingChannel.publishAsync({
|
|
||||||
url,
|
|
||||||
...pingResult,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pingAsync = async (url: string) => {
|
||||||
|
const pingResult = await sendPingRequestAsync(url);
|
||||||
|
|
||||||
|
if ("statusCode" in pingResult) {
|
||||||
|
logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pingChannel.publishAsync({
|
||||||
|
url,
|
||||||
|
...pingResult,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./client": "./client.ts",
|
"./client": "./client.ts",
|
||||||
"./schema/sqlite": "./schema/sqlite.ts",
|
"./schema/sqlite": "./schema/sqlite.ts",
|
||||||
"./test": "./test/index.ts"
|
"./test": "./test/index.ts",
|
||||||
|
"./queries": "./queries/index.ts"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"mysql2": "3.11.0",
|
"mysql2": "3.11.0",
|
||||||
"drizzle-kit": "^0.24.1"
|
"drizzle-kit": "^0.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/better-sqlite3": "7.6.11",
|
"@types/better-sqlite3": "7.6.11",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
|
|||||||
1
packages/db/queries/index.ts
Normal file
1
packages/db/queries/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./item";
|
||||||
47
packages/db/queries/item.ts
Normal file
47
packages/db/queries/item.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import type { Database } from "..";
|
||||||
|
import { inArray } from "..";
|
||||||
|
import type { inferSupportedIntegrations } from "../../widgets/src";
|
||||||
|
import { items } from "../schema/sqlite";
|
||||||
|
|
||||||
|
export const getItemsWithIntegrationsAsync = async <TKind extends WidgetKind>(
|
||||||
|
db: Database,
|
||||||
|
{ kinds }: { kinds: TKind[] },
|
||||||
|
) => {
|
||||||
|
const itemsForIntegration = await db.query.items.findMany({
|
||||||
|
where: inArray(items.kind, kinds),
|
||||||
|
with: {
|
||||||
|
integrations: {
|
||||||
|
with: {
|
||||||
|
integration: {
|
||||||
|
with: {
|
||||||
|
secrets: {
|
||||||
|
columns: {
|
||||||
|
kind: true,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return itemsForIntegration.map((item) => ({
|
||||||
|
...item,
|
||||||
|
kind: item.kind as TKind,
|
||||||
|
integrations: item.integrations.map(({ integration, integrationId }) => {
|
||||||
|
const integrationWithSecrets = {
|
||||||
|
...integration,
|
||||||
|
kind: integration.kind as inferSupportedIntegrations<TKind>,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
integration: integrationWithSecrets,
|
||||||
|
integrationId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const widgetKinds = [
|
|||||||
"smartHome-executeAutomation",
|
"smartHome-executeAutomation",
|
||||||
"mediaServer",
|
"mediaServer",
|
||||||
"calendar",
|
"calendar",
|
||||||
|
"mediaRequests-requestList",
|
||||||
|
"mediaRequests-requestStats",
|
||||||
"rssFeed",
|
"rssFeed",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/form": "^7.12.1",
|
"@mantine/form": "^7.12.2",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0"
|
"@homarr/translation": "workspace:^0.1.0"
|
||||||
},
|
},
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async disableAsync(duration?: number): Promise<void> {
|
public async disableAsync(duration = 0): Promise<void> {
|
||||||
const response = await fetch(`${this.integration.url}/control/protection`, {
|
const response = await fetch(`${this.integration.url}/control/protection`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -132,7 +132,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
duration: duration,
|
duration: duration * 1000,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -3,23 +3,29 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||||
|
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
import type { IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
|
|
||||||
export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => {
|
export const integrationCreatorByKind = <TKind extends keyof typeof integrationCreators>(
|
||||||
switch (kind) {
|
kind: TKind,
|
||||||
case "piHole":
|
integration: IntegrationInput,
|
||||||
return new PiHoleIntegration(integration);
|
) => {
|
||||||
case "adGuardHome":
|
if (!(kind in integrationCreators)) {
|
||||||
return new AdGuardHomeIntegration(integration);
|
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
|
||||||
case "homeAssistant":
|
|
||||||
return new HomeAssistantIntegration(integration);
|
|
||||||
case "jellyfin":
|
|
||||||
return new JellyfinIntegration(integration);
|
|
||||||
case "sonarr":
|
|
||||||
return new SonarrIntegration(integration);
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new integrationCreators[kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const integrationCreators = {
|
||||||
|
piHole: PiHoleIntegration,
|
||||||
|
adGuardHome: AdGuardHomeIntegration,
|
||||||
|
homeAssistant: HomeAssistantIntegration,
|
||||||
|
jellyfin: JellyfinIntegration,
|
||||||
|
sonarr: SonarrIntegration,
|
||||||
|
jellyseerr: JellyseerrIntegration,
|
||||||
|
overseerr: OverseerrIntegration,
|
||||||
|
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"
|
|||||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
|
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
|
||||||
|
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { StreamSession } from "./interfaces/media-server/session";
|
export type { StreamSession } from "./interfaces/media-server/session";
|
||||||
|
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||||
|
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export { integrationCreatorByKind } from "./base/creator";
|
export { integrationCreatorByKind } from "./base/creator";
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export interface MediaRequest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: "movie" | "tv";
|
||||||
|
backdropImageUrl: string;
|
||||||
|
posterImagePath: string;
|
||||||
|
href: string;
|
||||||
|
createdAt: Date;
|
||||||
|
airDate?: Date;
|
||||||
|
status: MediaRequestStatus;
|
||||||
|
availability: MediaAvailability;
|
||||||
|
requestedBy?: Omit<RequestUser, "requestCount">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaRequestList {
|
||||||
|
integration: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
medias: MediaRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestStats {
|
||||||
|
total: number;
|
||||||
|
movie: number;
|
||||||
|
tv: number;
|
||||||
|
pending: number;
|
||||||
|
approved: number;
|
||||||
|
declined: number;
|
||||||
|
processing: number;
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestUser {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
avatar: string;
|
||||||
|
requestCount: number;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaRequestStats {
|
||||||
|
integration: {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
stats: RequestStats;
|
||||||
|
users: RequestUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MediaRequestStatus {
|
||||||
|
PendingApproval = 1,
|
||||||
|
Approved = 2,
|
||||||
|
Declined = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MediaAvailability {
|
||||||
|
Unknown = 1,
|
||||||
|
Pending = 2,
|
||||||
|
Processing = 3,
|
||||||
|
PartiallyAvailable = 4,
|
||||||
|
Available = 5,
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
|
|
||||||
|
export class JellyseerrIntegration extends OverseerrIntegration {}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { appendPath } from "@homarr/common";
|
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -106,7 +105,7 @@ export class SonarrIntegration extends Integration {
|
|||||||
public async testConnectionAsync(): Promise<void> {
|
public async testConnectionAsync(): Promise<void> {
|
||||||
await super.handleTestConnectionResponseAsync({
|
await super.handleTestConnectionResponseAsync({
|
||||||
queryFunctionAsync: async () => {
|
queryFunctionAsync: async () => {
|
||||||
return await fetch(appendPath(this.integration.url, "/api/ping"), {
|
return await fetch(`${this.integration.url}/api`, {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
241
packages/integrations/src/overseerr/overseerr-integration.ts
Normal file
241
packages/integrations/src/overseerr/overseerr-integration.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
|
||||||
|
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overseerr Integration. See https://api-docs.overseerr.dev
|
||||||
|
*/
|
||||||
|
export class OverseerrIntegration extends Integration {
|
||||||
|
public async testConnectionAsync(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const json: object = await response.json();
|
||||||
|
if (Object.keys(json).includes("id")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Received response but unable to parse it: ${JSON.stringify(json)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
||||||
|
//Ensure to get all pending request first
|
||||||
|
const pendingRequests = await fetch(`${this.integration.url}/api/v1/request?take=-1&filter=pending`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//Change 20 to integration setting (set to -1 for all)
|
||||||
|
const allRequests = await fetch(`${this.integration.url}/api/v1/request?take=20`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
|
||||||
|
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
|
||||||
|
|
||||||
|
//Concat the 2 lists while remove any duplicate pending from the all items list
|
||||||
|
let requests;
|
||||||
|
|
||||||
|
if (pendingResults.length > 0 && allResults.length > 0) {
|
||||||
|
requests = pendingResults.concat(
|
||||||
|
allResults.filter(({ status }) => status !== MediaRequestStatus.PendingApproval),
|
||||||
|
);
|
||||||
|
} else if (pendingResults.length > 0) requests = pendingResults;
|
||||||
|
else if (allResults.length > 0) requests = allResults;
|
||||||
|
else return Promise.all([]);
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
requests.map(async (request): Promise<MediaRequest> => {
|
||||||
|
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
name: information.name,
|
||||||
|
status: request.status,
|
||||||
|
availability: request.media.status,
|
||||||
|
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
||||||
|
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
||||||
|
href: `${this.integration.url}/${request.type}/${request.media.tmdbId}`,
|
||||||
|
type: request.type,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
airDate: new Date(information.airDate),
|
||||||
|
requestedBy: request.requestedBy
|
||||||
|
? ({
|
||||||
|
...request.requestedBy,
|
||||||
|
displayName: request.requestedBy.displayName,
|
||||||
|
link: `${this.integration.url}/users/${request.requestedBy.id}`,
|
||||||
|
avatar: constructAvatarUrl(this.integration.url, request.requestedBy.avatar),
|
||||||
|
} satisfies Omit<RequestUser, "requestCount">)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStatsAsync(): Promise<RequestStats> {
|
||||||
|
const response = await fetch(`${this.integration.url}/api/v1/request/count`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await getStatsSchema.parseAsync(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUsersAsync(): Promise<RequestUser[]> {
|
||||||
|
const response = await fetch(`${this.integration.url}/api/v1/user?take=-1`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const users = (await getUsersSchema.parseAsync(await response.json())).results;
|
||||||
|
return users.map((user): RequestUser => {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
link: `${this.integration.url}/users/${user.id}`,
|
||||||
|
avatar: constructAvatarUrl(this.integration.url, user.avatar),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async approveRequestAsync(requestId: number): Promise<void> {
|
||||||
|
await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async declineRequestAsync(requestId: number): Promise<void> {
|
||||||
|
await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
||||||
|
const response = await fetch(`${this.integration.url}/api/v1/${type}/${id}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (type === "tv") {
|
||||||
|
const series = (await response.json()) as TvInformation;
|
||||||
|
return {
|
||||||
|
name: series.name,
|
||||||
|
backdropPath: series.backdropPath ?? series.posterPath,
|
||||||
|
posterPath: series.posterPath ?? series.backdropPath,
|
||||||
|
airDate: series.firstAirDate,
|
||||||
|
} satisfies MediaInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movie = (await response.json()) as MovieInformation;
|
||||||
|
return {
|
||||||
|
name: movie.title,
|
||||||
|
backdropPath: movie.backdropPath ?? movie.posterPath,
|
||||||
|
posterPath: movie.posterPath ?? movie.backdropPath,
|
||||||
|
airDate: movie.releaseDate,
|
||||||
|
} satisfies MediaInformation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constructAvatarUrl = (appUrl: string, avatar: string) => {
|
||||||
|
const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://");
|
||||||
|
|
||||||
|
if (isAbsolute) {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${appUrl}/${avatar}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MediaInformation {
|
||||||
|
name: string;
|
||||||
|
backdropPath?: string;
|
||||||
|
posterPath?: string;
|
||||||
|
airDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvInformation {
|
||||||
|
name: string;
|
||||||
|
backdropPath?: string;
|
||||||
|
posterPath?: string;
|
||||||
|
firstAirDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieInformation {
|
||||||
|
title: string;
|
||||||
|
backdropPath?: string;
|
||||||
|
posterPath?: string;
|
||||||
|
releaseDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRequestsSchema = z.object({
|
||||||
|
results: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
status: z.nativeEnum(MediaRequestStatus),
|
||||||
|
createdAt: z.string().transform((value) => new Date(value)),
|
||||||
|
media: z.object({
|
||||||
|
status: z.nativeEnum(MediaAvailability),
|
||||||
|
tmdbId: z.number(),
|
||||||
|
}),
|
||||||
|
type: z.enum(["movie", "tv"]),
|
||||||
|
requestedBy: z
|
||||||
|
.object({
|
||||||
|
id: z.number(),
|
||||||
|
displayName: z.string(),
|
||||||
|
avatar: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.transform((val) => {
|
||||||
|
if (!val) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatsSchema = z.object({
|
||||||
|
total: z.number(),
|
||||||
|
movie: z.number(),
|
||||||
|
tv: z.number(),
|
||||||
|
pending: z.number(),
|
||||||
|
approved: z.number(),
|
||||||
|
declined: z.number(),
|
||||||
|
processing: z.number(),
|
||||||
|
available: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUsersSchema = z.object({
|
||||||
|
results: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
displayName: z.string(),
|
||||||
|
avatar: z.string(),
|
||||||
|
requestCount: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.transform((val) => {
|
||||||
|
if (!val) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||||
export * from "./calendar-types";
|
export * from "./calendar-types";
|
||||||
|
export * from "./interfaces/media-requests/media-request";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("Home Assistant integration", () => {
|
|||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await startedContainer.stop();
|
await startedContainer.stop();
|
||||||
}, 20_000); // Timeout of 20 seconds
|
}, 30_000); // Timeout of 30 seconds
|
||||||
test("Test connection should fail with wrong credentials", async () => {
|
test("Test connection should fail with wrong credentials", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const startedContainer = await prepareHomeAssistantContainerAsync();
|
const startedContainer = await prepareHomeAssistantContainerAsync();
|
||||||
@@ -41,7 +41,7 @@ describe("Home Assistant integration", () => {
|
|||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await startedContainer.stop();
|
await startedContainer.stop();
|
||||||
}, 20_000); // Timeout of 20 seconds
|
}, 30_000); // Timeout of 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const prepareHomeAssistantContainerAsync = async () => {
|
const prepareHomeAssistantContainerAsync = async () => {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -24,14 +24,14 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"@mantine/core": "^7.12.1",
|
"@mantine/core": "^7.12.2",
|
||||||
"@mantine/hooks": "^7.12.1"
|
"@mantine/hooks": "^7.12.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -22,15 +22,15 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/notifications": "^7.12.1",
|
"@mantine/notifications": "^7.12.2",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@tabler/icons-react": "^3.12.0"
|
"@tabler/icons-react": "^3.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -24,12 +24,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.12.1",
|
"@mantine/core": "^7.12.2",
|
||||||
"@mantine/hooks": "^7.12.1",
|
"@mantine/hooks": "^7.12.2",
|
||||||
"@mantine/spotlight": "^7.12.1",
|
"@mantine/spotlight": "^7.12.2",
|
||||||
"@tabler/icons-react": "^3.12.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
"jotai": "^2.9.3",
|
"jotai": "^2.9.3",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ export default {
|
|||||||
colon: ": ",
|
colon: ": ",
|
||||||
},
|
},
|
||||||
error: "Error",
|
error: "Error",
|
||||||
|
errors: {
|
||||||
|
noData: "No data to show",
|
||||||
|
noIntegration: "No integration selected",
|
||||||
|
},
|
||||||
action: {
|
action: {
|
||||||
add: "Add",
|
add: "Add",
|
||||||
apply: "Apply",
|
apply: "Apply",
|
||||||
@@ -1115,6 +1119,50 @@ export default {
|
|||||||
description: "Show the current streams on your media servers",
|
description: "Show the current streams on your media servers",
|
||||||
option: {},
|
option: {},
|
||||||
},
|
},
|
||||||
|
"mediaRequests-requestList": {
|
||||||
|
name: "Media Requests List",
|
||||||
|
description: "See a list of all media requests from your Overseerr or Jellyseerr instance",
|
||||||
|
option: {
|
||||||
|
linksTargetNewTab: {
|
||||||
|
label: "Open links in new tab",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
approve: "Approve request",
|
||||||
|
approving: "Approving request...",
|
||||||
|
decline: "Decline request",
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
unknown: "Unknown",
|
||||||
|
pending: "Pending",
|
||||||
|
processing: "Processing",
|
||||||
|
partiallyAvailable: "Partial",
|
||||||
|
available: "Available",
|
||||||
|
},
|
||||||
|
toBeDetermined: "TBD",
|
||||||
|
},
|
||||||
|
"mediaRequests-requestStats": {
|
||||||
|
name: "Media Requests Stats",
|
||||||
|
description: "Statistics about your media requests",
|
||||||
|
option: {},
|
||||||
|
titles: {
|
||||||
|
stats: {
|
||||||
|
main: "Media Stats",
|
||||||
|
approved: "Already approved",
|
||||||
|
pending: "Pending approvals",
|
||||||
|
processing: "Being processed",
|
||||||
|
declined: "Already declined",
|
||||||
|
available: "Already Available",
|
||||||
|
tv: "TV requests",
|
||||||
|
movie: "Movie requests",
|
||||||
|
total: "Total",
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
main: "Top Users",
|
||||||
|
requests: "Requests",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
rssFeed: {
|
rssFeed: {
|
||||||
name: "RSS feeds",
|
name: "RSS feeds",
|
||||||
description: "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
description: "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
||||||
@@ -1366,6 +1414,7 @@ export default {
|
|||||||
items: {
|
items: {
|
||||||
docker: "Docker",
|
docker: "Docker",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
api: "API",
|
||||||
tasks: "Tasks",
|
tasks: "Tasks",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1602,6 +1651,9 @@ export default {
|
|||||||
mediaOrganizer: {
|
mediaOrganizer: {
|
||||||
label: "Media Organizers",
|
label: "Media Organizers",
|
||||||
},
|
},
|
||||||
|
mediaRequests: {
|
||||||
|
label: "Media Requests",
|
||||||
|
},
|
||||||
rssFeeds: {
|
rssFeeds: {
|
||||||
label: "RSS feeds",
|
label: "RSS feeds",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { useI18n } from "./client";
|
import type { useI18n, useScopedI18n } from "./client";
|
||||||
import type enTranslation from "./lang/en";
|
import type enTranslation from "./lang/en";
|
||||||
|
|
||||||
export type TranslationFunction = ReturnType<typeof useI18n>;
|
export type TranslationFunction = ReturnType<typeof useI18n>;
|
||||||
|
export type ScopedTranslationFunction<T extends Parameters<typeof useScopedI18n>[0]> = ReturnType<
|
||||||
|
typeof useScopedI18n<T>
|
||||||
|
>;
|
||||||
export type TranslationObject = typeof enTranslation;
|
export type TranslationObject = typeof enTranslation;
|
||||||
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
|
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.12.1",
|
"@mantine/core": "^7.12.2",
|
||||||
"@mantine/dates": "^7.12.1",
|
"@mantine/dates": "^7.12.2",
|
||||||
"@mantine/hooks": "^7.12.1",
|
"@mantine/hooks": "^7.12.2",
|
||||||
"@tabler/icons-react": "^3.12.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.7",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/css-modules": "^1.0.5",
|
"@types/css-modules": "^1.0.5",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -35,9 +35,9 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/hooks": "^7.12.1",
|
"@mantine/hooks": "^7.12.2",
|
||||||
"@mantine/core": "^7.12.1",
|
"@mantine/core": "^7.12.2",
|
||||||
"@tabler/icons-react": "^3.12.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
"@tiptap/extension-color": "2.6.6",
|
"@tiptap/extension-color": "2.6.6",
|
||||||
"@tiptap/extension-highlight": "2.6.6",
|
"@tiptap/extension-highlight": "2.6.6",
|
||||||
"@tiptap/extension-image": "2.6.6",
|
"@tiptap/extension-image": "2.6.6",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"@tiptap/starter-kit": "^2.6.6",
|
"@tiptap/starter-kit": "^2.6.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.7",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"video.js": "^8.17.3"
|
"video.js": "^8.17.3"
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function ClockWidget({ options }: WidgetComponentProps<"clock">)
|
|||||||
{dayjs(time).tz(timezone).format(timeFormat)}
|
{dayjs(time).tz(timezone).format(timeFormat)}
|
||||||
</Text>
|
</Text>
|
||||||
{options.showDate && (
|
{options.showDate && (
|
||||||
<Text className="clock-date-text" size="12.5cqmin" lineClamp={1}>
|
<Text className="clock-date-text" size="12.5cqmin" pt="1cqmin" lineClamp={1}>
|
||||||
{dayjs(time).tz(timezone).format(dateFormat)}
|
{dayjs(time).tz(timezone).format(dateFormat)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
interface TimerModalProps {
|
interface TimerModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
integrationIds: string[];
|
selectedIntegrationIds: string[];
|
||||||
disableDns: (data: { duration: number; integrationId: string }) => void;
|
disableDns: (data: { duration: number; integrationId: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimerModal = ({ opened, close, integrationIds, disableDns }: TimerModalProps) => {
|
const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [hours, setHours] = useState(0);
|
const [hours, setHours] = useState(0);
|
||||||
const [minutes, setMinutes] = useState(0);
|
const [minutes, setMinutes] = useState(0);
|
||||||
@@ -21,7 +21,7 @@ const TimerModal = ({ opened, close, integrationIds, disableDns }: TimerModalPro
|
|||||||
|
|
||||||
const handleSetTimer = () => {
|
const handleSetTimer = () => {
|
||||||
const duration = hours * 3600 + minutes * 60;
|
const duration = hours * 3600 + minutes * 60;
|
||||||
integrationIds.forEach((integrationId) => {
|
selectedIntegrationIds.forEach((integrationId) => {
|
||||||
disableDns({ duration, integrationId });
|
disableDns({ duration, integrationId });
|
||||||
});
|
});
|
||||||
setHours(0);
|
setHours(0);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
|
|||||||
const [status, setStatus] = useState<{ integrationId: string; enabled: boolean }[]>(
|
const [status, setStatus] = useState<{ integrationId: string; enabled: boolean }[]>(
|
||||||
integrationIds.map((id) => ({ integrationId: id, enabled: false })),
|
integrationIds.map((id) => ({ integrationId: id, enabled: false })),
|
||||||
);
|
);
|
||||||
|
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
||||||
const [opened, { close, open }] = useDisclosure(false);
|
const [opened, { close, open }] = useDisclosure(false);
|
||||||
|
|
||||||
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||||
@@ -67,8 +68,10 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const allEnabled = status.every((item) => item.enabled);
|
const enabledIntegrations = integrationIds.filter((id) => status.find((item) => item.integrationId === id)?.enabled);
|
||||||
const allDisabled = status.every((item) => !item.enabled);
|
const disabledIntegrations = integrationIds.filter(
|
||||||
|
(id) => !status.find((item) => item.integrationId === id)?.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex h="100%" direction="column" gap={0} p="2.5cqmin">
|
<Flex h="100%" direction="column" gap={0} p="2.5cqmin">
|
||||||
@@ -76,10 +79,8 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
|
|||||||
<Flex gap="2.5cqmin">
|
<Flex gap="2.5cqmin">
|
||||||
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => disabledIntegrations.forEach((integrationId) => enableDns({ integrationId }))}
|
||||||
integrationIds.forEach((integrationId) => enableDns({ integrationId }));
|
disabled={disabledIntegrations.length === 0}
|
||||||
}}
|
|
||||||
disabled={allEnabled}
|
|
||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -90,17 +91,25 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
|
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
|
||||||
<Button onClick={open} disabled={allDisabled} variant="light" color="yellow" fullWidth h="2rem">
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIntegrationIds(enabledIntegrations);
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
disabled={enabledIntegrations.length === 0}
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
fullWidth
|
||||||
|
h="2rem"
|
||||||
|
>
|
||||||
<IconClockPause size={20} />
|
<IconClockPause size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
|
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => enabledIntegrations.forEach((integrationId) => disableDns({ integrationId, duration: 0 }))}
|
||||||
integrationIds.forEach((integrationId) => disableDns({ integrationId, duration: 0 }));
|
disabled={enabledIntegrations.length === 0}
|
||||||
}}
|
|
||||||
disabled={allDisabled}
|
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -113,24 +122,49 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap="2.5cqmin" flex={1} justify={options.showToggleAllButtons ? "flex-end" : "space-evenly"}>
|
<Stack gap="2.5cqmin" flex={1} justify={options.showToggleAllButtons ? "flex-end" : "space-evenly"}>
|
||||||
{data.map((integrationData) =>
|
{data.map((integrationData) => (
|
||||||
ControlsCard(integrationData.integrationId, integrationData.integrationKind, toggleDns, status, open, t),
|
<ControlsCard
|
||||||
)}
|
key={integrationData.integrationId}
|
||||||
|
integrationId={integrationData.integrationId}
|
||||||
|
integrationKind={integrationData.integrationKind}
|
||||||
|
toggleDns={toggleDns}
|
||||||
|
status={status}
|
||||||
|
setSelectedIntegrationIds={setSelectedIntegrationIds}
|
||||||
|
open={open}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<TimerModal opened={opened} close={close} integrationIds={integrationIds} disableDns={disableDns} />
|
<TimerModal
|
||||||
|
opened={opened}
|
||||||
|
close={close}
|
||||||
|
selectedIntegrationIds={selectedIntegrationIds}
|
||||||
|
disableDns={disableDns}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ControlsCard = (
|
interface ControlsCardProps {
|
||||||
integrationId: string,
|
integrationId: string;
|
||||||
integrationKind: string,
|
integrationKind: string;
|
||||||
toggleDns: (integrationId: string) => void,
|
toggleDns: (integrationId: string) => void;
|
||||||
status: { integrationId: string; enabled: boolean }[],
|
status: { integrationId: string; enabled: boolean }[];
|
||||||
open: () => void,
|
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
||||||
t: TranslationFunction,
|
open: () => void;
|
||||||
) => {
|
t: TranslationFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||||
|
integrationId,
|
||||||
|
integrationKind,
|
||||||
|
toggleDns,
|
||||||
|
status,
|
||||||
|
setSelectedIntegrationIds,
|
||||||
|
open,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
const integrationStatus = status.find((item) => item.integrationId === integrationId);
|
const integrationStatus = status.find((item) => item.integrationId === integrationId);
|
||||||
const isEnabled = integrationStatus?.enabled ?? false;
|
const isEnabled = integrationStatus?.enabled ?? false;
|
||||||
const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome;
|
const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome;
|
||||||
@@ -149,7 +183,17 @@ const ControlsCard = (
|
|||||||
{t(`widget.dnsHoleControls.controls.${isEnabled ? "enabled" : "disabled"}`)}
|
{t(`widget.dnsHoleControls.controls.${isEnabled ? "enabled" : "disabled"}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<ActionIcon disabled={!isEnabled} size={20} radius="xl" top="2.67px" variant="default" onClick={open}>
|
<ActionIcon
|
||||||
|
disabled={!isEnabled}
|
||||||
|
size={20}
|
||||||
|
radius="xl"
|
||||||
|
top="2.67px"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIntegrationIds([integrationId]);
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<IconClockPause size={20} color="red" />
|
<IconClockPause size={20} color="red" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import * as dnsHoleControls from "./dns-hole/controls";
|
|||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
|
import * as mediaRequestsList from "./media-requests/list";
|
||||||
|
import * as mediaRequestsStats from "./media-requests/stats";
|
||||||
import * as mediaServer from "./media-server";
|
import * as mediaServer from "./media-server";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
@@ -42,6 +44,8 @@ export const widgetImports = {
|
|||||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||||
mediaServer,
|
mediaServer,
|
||||||
calendar,
|
calendar,
|
||||||
|
"mediaRequests-requestList": mediaRequestsList,
|
||||||
|
"mediaRequests-requestStats": mediaRequestsStats,
|
||||||
rssFeed,
|
rssFeed,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
@@ -64,3 +68,9 @@ export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
|
|||||||
loadedComponents.set(kind, newlyLoadedComponent as never);
|
loadedComponents.set(kind, newlyLoadedComponent as never);
|
||||||
return newlyLoadedComponent;
|
return newlyLoadedComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
|
||||||
|
supportedIntegrations: string[];
|
||||||
|
}
|
||||||
|
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||||
|
: string[])[number];
|
||||||
|
|||||||
230
packages/widgets/src/media-requests/list/component.tsx
Normal file
230
packages/widgets/src/media-requests/list/component.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { MediaAvailability, MediaRequestStatus } from "@homarr/integrations/types";
|
||||||
|
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
|
|
||||||
|
export default function MediaServerWidget({
|
||||||
|
integrationIds,
|
||||||
|
isEditMode,
|
||||||
|
options,
|
||||||
|
serverData,
|
||||||
|
itemId,
|
||||||
|
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||||
|
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||||
|
const tCommon = useScopedI18n("common");
|
||||||
|
const isQueryEnabled = Boolean(itemId);
|
||||||
|
const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
|
||||||
|
{
|
||||||
|
integrationIds,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
itemId: itemId!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: !serverData ? undefined : serverData.initialData,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
enabled: integrationIds.length > 0 && isQueryEnabled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedMediaRequests = useMemo(
|
||||||
|
() =>
|
||||||
|
mediaRequests
|
||||||
|
?.filter((group) => group != null)
|
||||||
|
.flatMap((group) => group.data)
|
||||||
|
.flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id })))
|
||||||
|
.sort(({ status: statusA }, { status: statusB }) => {
|
||||||
|
if (statusA === MediaRequestStatus.PendingApproval) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (statusB === MediaRequestStatus.PendingApproval) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}) ?? [],
|
||||||
|
[mediaRequests, integrationIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
|
||||||
|
|
||||||
|
if (integrationIds.length === 0) return <Center h="100%">{tCommon("errors.noIntegration")}</Center>;
|
||||||
|
|
||||||
|
if (sortedMediaRequests.length === 0) return <Center h="100%">{tCommon("errors.noData")}</Center>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
className="mediaRequests-list-scrollArea"
|
||||||
|
scrollbarSize="2cqmin"
|
||||||
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
|
>
|
||||||
|
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
|
||||||
|
{sortedMediaRequests.map((mediaRequest) => (
|
||||||
|
<Card
|
||||||
|
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
|
||||||
|
key={mediaRequest.id}
|
||||||
|
h="20cqmin"
|
||||||
|
radius="2cqmin"
|
||||||
|
p="2cqmin"
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="mediaRequests-list-item-background"
|
||||||
|
src={mediaRequest.backdropImageUrl}
|
||||||
|
pos="absolute"
|
||||||
|
w="100%"
|
||||||
|
h="100%"
|
||||||
|
opacity={0.2}
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
className="mediaRequests-list-item-contents"
|
||||||
|
h="100%"
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
gap={0}
|
||||||
|
>
|
||||||
|
<Group className="mediaRequests-list-item-left-side" h="100%" gap="4cqmin" wrap="nowrap" flex={1}>
|
||||||
|
<Image
|
||||||
|
className="mediaRequests-list-item-poster"
|
||||||
|
src={mediaRequest.posterImagePath}
|
||||||
|
h="100%"
|
||||||
|
w="10cqmin"
|
||||||
|
radius="1cqmin"
|
||||||
|
/>
|
||||||
|
<Stack className="mediaRequests-list-item-media-infos" gap="1cqmin">
|
||||||
|
<Group className="mediaRequests-list-item-info-first-line" gap="2cqmin" wrap="nowrap">
|
||||||
|
<Text className="mediaRequests-list-item-media-year" size="3.5cqmin" pt="0.75cqmin">
|
||||||
|
{mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
className="mediaRequests-list-item-media-status"
|
||||||
|
color={getAvailabilityProperties(mediaRequest.availability, t).color}
|
||||||
|
variant="light"
|
||||||
|
fz="3.5cqmin"
|
||||||
|
lh="4cqmin"
|
||||||
|
size="5cqmin"
|
||||||
|
pt="0.75cqmin"
|
||||||
|
px="2cqmin"
|
||||||
|
>
|
||||||
|
{getAvailabilityProperties(mediaRequest.availability, t).label}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Anchor
|
||||||
|
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||||
|
href={mediaRequest.href}
|
||||||
|
c="var(--mantine-color-text)"
|
||||||
|
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||||
|
fz="5cqmin"
|
||||||
|
lineClamp={1}
|
||||||
|
>
|
||||||
|
{mediaRequest.name || "unknown"}
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Stack className="mediaRequests-list-item-right-side" gap="1cqmin" align="end">
|
||||||
|
<Group className="mediaRequests-list-item-request-user" gap="2cqmin" wrap="nowrap">
|
||||||
|
<Avatar
|
||||||
|
className="mediaRequests-list-item-request-user-avatar"
|
||||||
|
src={mediaRequest.requestedBy?.avatar}
|
||||||
|
size="6cqmin"
|
||||||
|
/>
|
||||||
|
<Anchor
|
||||||
|
className="mediaRequests-list-item-request-user-name"
|
||||||
|
href={mediaRequest.requestedBy?.link}
|
||||||
|
c="var(--mantine-color-text)"
|
||||||
|
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||||
|
fz="5cqmin"
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ wordBreak: "break-all" }}
|
||||||
|
>
|
||||||
|
{(mediaRequest.requestedBy?.displayName ?? "") || "unknown"}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
{mediaRequest.status === MediaRequestStatus.PendingApproval && (
|
||||||
|
<Group className="mediaRequests-list-item-pending-buttons" gap="2cqmin">
|
||||||
|
<Tooltip label={t("pending.approve")}>
|
||||||
|
<ActionIcon
|
||||||
|
className="mediaRequests-list-item-pending-button-approve"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="5cqmin"
|
||||||
|
onClick={() => {
|
||||||
|
mutateRequestAnswer({
|
||||||
|
integrationId: mediaRequest.integrationId,
|
||||||
|
requestId: mediaRequest.id,
|
||||||
|
answer: "approve",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconThumbUp size="4cqmin" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t("pending.decline")}>
|
||||||
|
<ActionIcon
|
||||||
|
className="mediaRequests-list-item-pending-button-decline"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="5cqmin"
|
||||||
|
onClick={() => {
|
||||||
|
mutateRequestAnswer({
|
||||||
|
integrationId: mediaRequest.integrationId,
|
||||||
|
requestId: mediaRequest.id,
|
||||||
|
answer: "decline",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconThumbDown size="4cqmin" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailabilityProperties(
|
||||||
|
mediaRequestAvailability: MediaAvailability,
|
||||||
|
t: ScopedTranslationFunction<"widget.mediaRequests-requestList">,
|
||||||
|
) {
|
||||||
|
switch (mediaRequestAvailability) {
|
||||||
|
case MediaAvailability.Available:
|
||||||
|
return { color: "green", label: t("availability.available") };
|
||||||
|
case MediaAvailability.PartiallyAvailable:
|
||||||
|
return { color: "yellow", label: t("availability.partiallyAvailable") };
|
||||||
|
case MediaAvailability.Pending:
|
||||||
|
return { color: "violet", label: t("availability.pending") };
|
||||||
|
case MediaAvailability.Processing:
|
||||||
|
return { color: "blue", label: t("availability.processing") };
|
||||||
|
default:
|
||||||
|
return { color: "red", label: t("availability.unknown") };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/widgets/src/media-requests/list/index.ts
Normal file
16
packages/widgets/src/media-requests/list/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { IconZoomQuestion } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../../definition";
|
||||||
|
import { optionsBuilder } from "../../options";
|
||||||
|
|
||||||
|
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestList", {
|
||||||
|
icon: IconZoomQuestion,
|
||||||
|
options: optionsBuilder.from((factory) => ({
|
||||||
|
linksTargetNewTab: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
supportedIntegrations: ["overseerr", "jellyseerr"],
|
||||||
|
})
|
||||||
|
.withServerData(() => import("./serverData"))
|
||||||
|
.withDynamicImport(() => import("./component"));
|
||||||
22
packages/widgets/src/media-requests/list/serverData.ts
Normal file
22
packages/widgets/src/media-requests/list/serverData.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
|
import type { WidgetProps } from "../../definition";
|
||||||
|
|
||||||
|
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"mediaRequests-requestList">) {
|
||||||
|
if (integrationIds.length === 0 || !itemId) {
|
||||||
|
return {
|
||||||
|
initialData: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = await api.widget.mediaRequests.getLatestRequests({
|
||||||
|
integrationIds,
|
||||||
|
itemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialData: requests.filter((group) => group != null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.gridElement:not(:nth-child(8n)) {
|
||||||
|
border-right: 0.5cqmin solid var(--app-shell-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridElement:not(:nth-last-child(-n + 8)) {
|
||||||
|
border-bottom: 0.5cqmin solid var(--app-shell-border-color);
|
||||||
|
}
|
||||||
220
packages/widgets/src/media-requests/stats/component.tsx
Normal file
220
packages/widgets/src/media-requests/stats/component.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ActionIcon, Avatar, Card, Center, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
import type { Icon } from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
IconDeviceTv,
|
||||||
|
IconExternalLink,
|
||||||
|
IconHourglass,
|
||||||
|
IconLoaderQuarter,
|
||||||
|
IconMovie,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconReceipt,
|
||||||
|
IconThumbDown,
|
||||||
|
IconThumbUp,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { RequestStats } from "../../../../integrations/src/interfaces/media-requests/media-request";
|
||||||
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
|
export default function MediaServerWidget({
|
||||||
|
integrationIds,
|
||||||
|
isEditMode,
|
||||||
|
serverData,
|
||||||
|
itemId,
|
||||||
|
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||||
|
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
||||||
|
const tCommon = useScopedI18n("common");
|
||||||
|
const isQueryEnabled = Boolean(itemId);
|
||||||
|
const { data: requestStats, isError: _isError } = clientApi.widget.mediaRequests.getStats.useQuery(
|
||||||
|
{
|
||||||
|
integrationIds,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
itemId: itemId!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: !serverData ? undefined : serverData.initialData,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
enabled: integrationIds.length > 0 && isQueryEnabled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { width, height, ref } = useElementSize();
|
||||||
|
|
||||||
|
const baseData = useMemo(
|
||||||
|
() => requestStats?.filter((group) => group != null).flatMap((group) => group.data) ?? [],
|
||||||
|
[requestStats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = useMemo(() => baseData.flatMap(({ stats }) => stats), [baseData]);
|
||||||
|
const users = useMemo(
|
||||||
|
() =>
|
||||||
|
baseData
|
||||||
|
.flatMap(({ integration, users }) =>
|
||||||
|
users.flatMap((user) => ({ ...user, appKind: integration.kind, appName: integration.name })),
|
||||||
|
)
|
||||||
|
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
|
||||||
|
[baseData],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (integrationIds.length === 0)
|
||||||
|
return (
|
||||||
|
<Center ref={ref} h="100%">
|
||||||
|
{tCommon("errors.noIntegration")}
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0 || stats.length === 0)
|
||||||
|
return (
|
||||||
|
<Center ref={ref} h="100%">
|
||||||
|
{tCommon("errors.noData")}
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
|
||||||
|
//Add processing and available
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
name: "approved",
|
||||||
|
icon: IconThumbUp,
|
||||||
|
number: stats.reduce((count, { approved }) => count + approved, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending",
|
||||||
|
icon: IconHourglass,
|
||||||
|
number: stats.reduce((count, { pending }) => count + pending, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "processing",
|
||||||
|
icon: IconLoaderQuarter,
|
||||||
|
number: stats.reduce((count, { processing }) => count + processing, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declined",
|
||||||
|
icon: IconThumbDown,
|
||||||
|
number: stats.reduce((count, { declined }) => count + declined, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "available",
|
||||||
|
icon: IconPlayerPlay,
|
||||||
|
number: stats.reduce((count, { available }) => count + available, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tv",
|
||||||
|
icon: IconDeviceTv,
|
||||||
|
number: stats.reduce((count, { tv }) => count + tv, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "movie",
|
||||||
|
icon: IconMovie,
|
||||||
|
number: stats.reduce((count, { movie }) => count + movie, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "total",
|
||||||
|
icon: IconReceipt,
|
||||||
|
number: stats.reduce((count, { total }) => count + total, 0),
|
||||||
|
},
|
||||||
|
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
className="mediaRequests-stats-layout"
|
||||||
|
display="flex"
|
||||||
|
h="100%"
|
||||||
|
gap="2cqmin"
|
||||||
|
p="2cqmin"
|
||||||
|
align="center"
|
||||||
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
|
>
|
||||||
|
<Text className="mediaRequests-stats-stats-title" size="6.5cqmin">
|
||||||
|
{t("titles.stats.main")}
|
||||||
|
</Text>
|
||||||
|
<Grid className="mediaRequests-stats-stats-grid" gutter={0} w="100%">
|
||||||
|
{data.map((stat) => (
|
||||||
|
<Grid.Col
|
||||||
|
className={combineClasses(
|
||||||
|
classes.gridElement,
|
||||||
|
"mediaRequests-stats-stat-wrapper",
|
||||||
|
`mediaRequests-stats-stat-${stat.name}`,
|
||||||
|
)}
|
||||||
|
key={stat.name}
|
||||||
|
span={3}
|
||||||
|
>
|
||||||
|
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||||
|
<Stack className="mediaRequests-stats-stat-stack" align="center" gap="2cqmin" p="2cqmin">
|
||||||
|
<stat.icon className="mediaRequests-stats-stat-icon" size="7.5cqmin" />
|
||||||
|
<Text className="mediaRequests-stats-stat-value" size="5cqmin">
|
||||||
|
{stat.number}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
<Text className="mediaRequests-stats-users-title" size="6.5cqmin">
|
||||||
|
{t("titles.users.main")}
|
||||||
|
</Text>
|
||||||
|
<Stack
|
||||||
|
className="mediaRequests-stats-users-wrapper"
|
||||||
|
flex={1}
|
||||||
|
w="100%"
|
||||||
|
ref={ref}
|
||||||
|
display="flex"
|
||||||
|
gap="2cqmin"
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
|
||||||
|
<Card
|
||||||
|
className={combineClasses(
|
||||||
|
"mediaRequests-stats-users-user-wrapper",
|
||||||
|
`mediaRequests-stats-users-user-${user.id}`,
|
||||||
|
)}
|
||||||
|
key={user.id}
|
||||||
|
withBorder
|
||||||
|
p="2cqmin"
|
||||||
|
flex={1}
|
||||||
|
mah="38.5cqmin"
|
||||||
|
radius="2.5cqmin"
|
||||||
|
>
|
||||||
|
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
|
||||||
|
<Tooltip label={user.appName}>
|
||||||
|
<Avatar
|
||||||
|
className="mediaRequests-stats-users-user-avatar"
|
||||||
|
size="12.5cqmin"
|
||||||
|
src={user.avatar}
|
||||||
|
bd={`0.5cqmin solid ${user.appKind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">
|
||||||
|
<Text className="mediaRequests-stats-users-user-userName" size="6cqmin">
|
||||||
|
{user.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text className="mediaRequests-stats-users-user-request-count" size="4cqmin">
|
||||||
|
{tCommon("rtl", { value: t("titles.users.requests"), symbol: tCommon("symbols.colon") }) +
|
||||||
|
user.requestCount}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Space flex={1} />
|
||||||
|
<ActionIcon
|
||||||
|
className="mediaRequests-stats-users-user-link-button"
|
||||||
|
variant="light"
|
||||||
|
color="var(--mantine-color-text)"
|
||||||
|
size="10cqmin"
|
||||||
|
component="a"
|
||||||
|
href={user.link}
|
||||||
|
>
|
||||||
|
<IconExternalLink className="mediaRequests-stats-users-user-link-icon" size="7.5cqmin" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
packages/widgets/src/media-requests/stats/index.ts
Normal file
11
packages/widgets/src/media-requests/stats/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IconChartBar } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../../definition";
|
||||||
|
|
||||||
|
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||||
|
icon: IconChartBar,
|
||||||
|
options: {},
|
||||||
|
supportedIntegrations: ["overseerr", "jellyseerr"],
|
||||||
|
})
|
||||||
|
.withServerData(() => import("./serverData"))
|
||||||
|
.withDynamicImport(() => import("./component"));
|
||||||
25
packages/widgets/src/media-requests/stats/serverData.ts
Normal file
25
packages/widgets/src/media-requests/stats/serverData.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
|
import type { WidgetProps } from "../../definition";
|
||||||
|
|
||||||
|
export default async function getServerDataAsync({
|
||||||
|
integrationIds,
|
||||||
|
itemId,
|
||||||
|
}: WidgetProps<"mediaRequests-requestStats">) {
|
||||||
|
if (integrationIds.length === 0 || !itemId) {
|
||||||
|
return {
|
||||||
|
initialData: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await api.widget.mediaRequests.getStats({
|
||||||
|
integrationIds,
|
||||||
|
itemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialData: stats.filter((group) => group != null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { WeatherDescription, WeatherIcon } from "./icon";
|
import { WeatherDescription, WeatherIcon } from "./icon";
|
||||||
|
|
||||||
export default function WeatherWidget({ options }: WidgetComponentProps<"weather">) {
|
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
|
||||||
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
|
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
latitude: options.location.latitude,
|
latitude: options.location.latitude,
|
||||||
@@ -23,7 +23,14 @@ export default function WeatherWidget({ options }: WidgetComponentProps<"weather
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center" gap="0" w="100%" h="100%">
|
<Stack
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="0"
|
||||||
|
w="100%"
|
||||||
|
h="100%"
|
||||||
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
|
>
|
||||||
{options.hasForecast ? (
|
{options.hasForecast ? (
|
||||||
<WeeklyForecast weather={weather} options={options} />
|
<WeeklyForecast weather={weather} options={options} />
|
||||||
) : (
|
) : (
|
||||||
@@ -51,15 +58,15 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
|||||||
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
|
<Text fz="17.5cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Space h="1cqmin" />
|
<Space h="1cqmin" />
|
||||||
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
|
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
|
||||||
<IconArrowUpRight size="12.5cqmin" />
|
<IconArrowUpRight size="12.5cqmin" />
|
||||||
<Text fz="12.5cqmin">{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}</Text>
|
<Text fz="10cqmin">{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}</Text>
|
||||||
<Space w="2.5cqmin" />
|
<Space w="2.5cqmin" />
|
||||||
<IconArrowDownRight size="12.5cqmin" />
|
<IconArrowDownRight size="12.5cqmin" />
|
||||||
<Text fz="12.5cqmin">{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}</Text>
|
<Text fz="10cqmin">{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{options.showCity && (
|
{options.showCity && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
2152
patches/trpc-swagger@1.2.6.patch
Normal file
2152
patches/trpc-swagger@1.2.6.patch
Normal file
File diff suppressed because it is too large
Load Diff
4103
pnpm-lock.yaml
generated
4103
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -16,19 +16,19 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "^14.2.6",
|
"@next/eslint-plugin-next": "^14.2.7",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-config-turbo": "^2.0.14",
|
"eslint-config-turbo": "^2.1.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||||
"eslint-plugin-react": "^7.35.0",
|
"eslint-plugin-react": "^7.35.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"typescript-eslint": "^8.2.0"
|
"typescript-eslint": "^8.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.1",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
Reference in New Issue
Block a user