chore(release): automatic release v1.30.0
This commit is contained in:
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -31,6 +31,7 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Homarr are you running?
|
description: What version of Homarr are you running?
|
||||||
options:
|
options:
|
||||||
|
- 1.29.0
|
||||||
- 1.28.1
|
- 1.28.1
|
||||||
- 1.28.0
|
- 1.28.0
|
||||||
- 1.27.0
|
- 1.27.0
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ ENV DB_URL='/appdata/db/db.sqlite'
|
|||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
ENV DB_DRIVER='better-sqlite3'
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
ENV AUTH_PROVIDERS='credentials'
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
|
ENV REDIS_IS_EXTERNAL='false'
|
||||||
ENV NODE_ENV='production'
|
ENV NODE_ENV='production'
|
||||||
|
|
||||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||||
|
|||||||
@@ -25,15 +25,16 @@
|
|||||||
"@homarr/boards": "workspace:^0.1.0",
|
"@homarr/boards": "workspace:^0.1.0",
|
||||||
"@homarr/certificates": "workspace:^0.1.0",
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/docker": "workspace:^0.1.0",
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.12.0",
|
"@homarr/gridstack": "^1.12.0",
|
||||||
"@homarr/icons": "workspace:^0.1.0",
|
"@homarr/icons": "workspace:^0.1.0",
|
||||||
|
"@homarr/image-proxy": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"@mantine/modals": "^8.1.3",
|
"@mantine/modals": "^8.1.3",
|
||||||
"@mantine/tiptap": "^8.1.3",
|
"@mantine/tiptap": "^8.1.3",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@tanstack/react-query-devtools": "^5.83.0",
|
"@tanstack/react-query-devtools": "^5.83.0",
|
||||||
"@tanstack/react-query-next-experimental": "^5.83.0",
|
"@tanstack/react-query-next-experimental": "^5.83.0",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.5",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
19
apps/nextjs/src/app/api/image-proxy/[id]/route.ts
Normal file
19
apps/nextjs/src/app/api/image-proxy/[id]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { ImageProxy } from "@homarr/image-proxy";
|
||||||
|
|
||||||
|
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
|
||||||
|
const { id } = await props.params;
|
||||||
|
|
||||||
|
const imageProxy = new ImageProxy();
|
||||||
|
const image = await imageProxy.forwardImageAsync(id);
|
||||||
|
if (!image) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(image, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createEnv } from "@homarr/env";
|
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||||
import { createBooleanSchema } from "@homarr/env/schemas";
|
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"main": "./src/main.ts",
|
"main": "./src/main.ts",
|
||||||
"types": "./src/main.ts",
|
"types": "./src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
|
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --external:bcrypt --outfile=tasks.cjs",
|
||||||
"clean": "rm -rf .turbo node_modules",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^22.16.4",
|
"@types/node": "^22.16.4",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"esbuild": "^0.25.6",
|
"esbuild": "^0.25.8",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tsx": "4.20.3",
|
"tsx": "4.20.3",
|
||||||
|
|||||||
@@ -34,7 +34,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.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"esbuild": "^0.25.6",
|
"esbuild": "^0.25.8",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||||
|
import { createRedisContainer } from "./shared/redis-container";
|
||||||
|
|
||||||
describe("Health checks", () => {
|
describe("Health checks", () => {
|
||||||
test("ready and live should return 200 OK", async () => {
|
test("ready and live should return 200 OK with normal image and no extra configuration", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const homarrContainer = await createHomarrContainer().start();
|
const homarrContainer = await createHomarrContainer().start();
|
||||||
|
|
||||||
@@ -15,4 +16,31 @@ describe("Health checks", () => {
|
|||||||
expect(readyResponse.status).toBe(200);
|
expect(readyResponse.status).toBe(200);
|
||||||
expect(liveResponse.status).toBe(200);
|
expect(liveResponse.status).toBe(200);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
test("ready and live should return 200 OK with external redis", async () => {
|
||||||
|
// Arrange
|
||||||
|
const redisContainer = await createRedisContainer().start();
|
||||||
|
const homarrContainer = await createHomarrContainer({
|
||||||
|
environment: {
|
||||||
|
REDIS_IS_EXTERNAL: "true",
|
||||||
|
REDIS_HOST: "host.docker.internal",
|
||||||
|
REDIS_PORT: redisContainer.getMappedPort(6379).toString(),
|
||||||
|
REDIS_PASSWORD: redisContainer.getPassword(),
|
||||||
|
},
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
||||||
|
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(
|
||||||
|
readyResponse.status,
|
||||||
|
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
liveResponse.status,
|
||||||
|
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
|
||||||
|
).toBe(200);
|
||||||
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|||||||
5
e2e/shared/redis-container.ts
Normal file
5
e2e/shared/redis-container.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RedisContainer } from "@testcontainers/redis";
|
||||||
|
|
||||||
|
export const createRedisContainer = () => {
|
||||||
|
return new RedisContainer("redis:latest").withPassword("homarr");
|
||||||
|
};
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@semantic-release/github": "^11.0.3",
|
"@semantic-release/github": "^11.0.3",
|
||||||
"@semantic-release/npm": "^12.0.2",
|
"@semantic-release/npm": "^12.0.2",
|
||||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
|
"@testcontainers/redis": "^11.3.1",
|
||||||
"@turbo/gen": "^2.5.5",
|
"@turbo/gen": "^2.5.5",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"semantic-release": "^24.2.7",
|
"semantic-release": "^24.2.7",
|
||||||
"testcontainers": "^11.2.1",
|
"testcontainers": "^11.3.1",
|
||||||
"turbo": "^2.5.5",
|
"turbo": "^2.5.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"@trpc/server": "^11.4.3",
|
"@trpc/server": "^11.4.3",
|
||||||
"@trpc/tanstack-react-query": "^11.4.3",
|
"@trpc/tanstack-react-query": "^11.4.3",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { dnsHoleRouter } from "./dns-hole";
|
|||||||
import { downloadsRouter } from "./downloads";
|
import { downloadsRouter } from "./downloads";
|
||||||
import { healthMonitoringRouter } from "./health-monitoring";
|
import { healthMonitoringRouter } from "./health-monitoring";
|
||||||
import { indexerManagerRouter } from "./indexer-manager";
|
import { indexerManagerRouter } from "./indexer-manager";
|
||||||
|
import { mediaReleaseRouter } from "./media-release";
|
||||||
import { mediaRequestsRouter } from "./media-requests";
|
import { mediaRequestsRouter } from "./media-requests";
|
||||||
import { mediaServerRouter } from "./media-server";
|
import { mediaServerRouter } from "./media-server";
|
||||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||||
@@ -27,6 +28,7 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
smartHome: smartHomeRouter,
|
smartHome: smartHomeRouter,
|
||||||
stockPrice: stockPriceRouter,
|
stockPrice: stockPriceRouter,
|
||||||
mediaServer: mediaServerRouter,
|
mediaServer: mediaServerRouter,
|
||||||
|
mediaRelease: mediaReleaseRouter,
|
||||||
calendar: calendarRouter,
|
calendar: calendarRouter,
|
||||||
downloads: downloadsRouter,
|
downloads: downloadsRouter,
|
||||||
mediaRequests: mediaRequestsRouter,
|
mediaRequests: mediaRequestsRouter,
|
||||||
|
|||||||
67
packages/api/src/router/widgets/media-release.ts
Normal file
67
packages/api/src/router/widgets/media-release.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
|
import type { Integration } from "@homarr/db/schema";
|
||||||
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
|
import type { MediaRelease } from "@homarr/integrations/types";
|
||||||
|
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||||
|
|
||||||
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
export const mediaReleaseRouter = createTRPCRouter({
|
||||||
|
getMediaReleases: publicProcedure
|
||||||
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
ctx.integrations.map(async (integration) => {
|
||||||
|
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||||
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
integration: {
|
||||||
|
id: integration.id,
|
||||||
|
name: integration.name,
|
||||||
|
kind: integration.kind,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
releases: data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results.flatMap((result) =>
|
||||||
|
result.releases.map((release) => ({
|
||||||
|
...release,
|
||||||
|
integration: result.integration,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
subscribeToReleases: publicProcedure
|
||||||
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||||
|
.subscription(({ ctx }) => {
|
||||||
|
return observable<{
|
||||||
|
integration: Modify<Integration, { kind: IntegrationKindByCategory<"mediaRelease"> }>;
|
||||||
|
releases: MediaRelease[];
|
||||||
|
}>((emit) => {
|
||||||
|
const unsubscribes: (() => void)[] = [];
|
||||||
|
for (const integrationWithSecrets of ctx.integrations) {
|
||||||
|
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||||
|
const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
|
||||||
|
const unsubscribe = innerHandler.subscribe((releases) => {
|
||||||
|
emit.next({
|
||||||
|
integration,
|
||||||
|
releases,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
unsubscribes.push(unsubscribe);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
unsubscribes.forEach((unsubscribe) => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createBooleanSchema, createDurationSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||||
import { supportedAuthProviders } from "@homarr/definitions";
|
import { supportedAuthProviders } from "@homarr/definitions";
|
||||||
import { createEnv } from "@homarr/env";
|
|
||||||
import { createBooleanSchema, createDurationSchema } from "@homarr/env/schemas";
|
|
||||||
|
|
||||||
const authProvidersSchema = z
|
const authProvidersSchema = z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -27,15 +27,15 @@
|
|||||||
"@auth/drizzle-adapter": "^1.10.0",
|
"@auth/drizzle-adapter": "^1.10.0",
|
||||||
"@homarr/certificates": "workspace:^0.1.0",
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "8.0.6",
|
"ldapts": "8.0.6",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"next-auth": "5.0.0-beta.29",
|
"next-auth": "5.0.0-beta.29",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@@ -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",
|
||||||
"esbuild": "^0.25.6",
|
"esbuild": "^0.25.8",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createEnv } from "@homarr/env";
|
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||||
|
|
||||||
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
|
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"undici": "7.12.0",
|
"undici": "7.12.0",
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export * from "./error";
|
|||||||
export * from "./fetch-with-timeout";
|
export * from "./fetch-with-timeout";
|
||||||
export * from "./theme";
|
export * from "./theme";
|
||||||
export * from "./function";
|
export * from "./function";
|
||||||
|
export * from "./id";
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import baseConfig from "@homarr/eslint-config/base";
|
import baseConfig from "@homarr/eslint-config/base";
|
||||||
|
|
||||||
/** @type {import('typescript-eslint').Config} */
|
/** @type {import('typescript-eslint').Config} */
|
||||||
export default [
|
export default [...baseConfig];
|
||||||
{
|
|
||||||
ignores: [],
|
|
||||||
},
|
|
||||||
...baseConfig,
|
|
||||||
];
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@homarr/env",
|
"name": "@homarr/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
"./infrastructure/redis": "./src/infrastructure/redis/client.ts",
|
||||||
"./schemas": "./src/schemas.ts"
|
"./infrastructure/env": "./src/infrastructure/env/index.ts",
|
||||||
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.8",
|
||||||
|
"ioredis": "5.6.1",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -7,3 +7,6 @@ export const defaultEnvOptions = {
|
|||||||
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
|
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
|
||||||
|
|
||||||
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });
|
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });
|
||||||
|
|
||||||
|
export * from "./prefix";
|
||||||
|
export * from "./schemas";
|
||||||
13
packages/core/src/infrastructure/env/prefix.ts
vendored
Normal file
13
packages/core/src/infrastructure/env/prefix.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const runtimeEnvWithPrefix = (prefix: `${string}_`) =>
|
||||||
|
Object.entries(process.env)
|
||||||
|
.filter(([key]) => key.startsWith(prefix))
|
||||||
|
.reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
if (value === undefined) return acc;
|
||||||
|
|
||||||
|
const newKey = key.replace(prefix, "");
|
||||||
|
acc[newKey] = value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
26
packages/core/src/infrastructure/redis/client.ts
Normal file
26
packages/core/src/infrastructure/redis/client.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { RedisOptions } from "ioredis";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
|
||||||
|
import { redisEnv } from "./env";
|
||||||
|
|
||||||
|
const defaultRedisOptions = {
|
||||||
|
connectionName: "homarr",
|
||||||
|
} satisfies RedisOptions;
|
||||||
|
|
||||||
|
export type { Redis as RedisClient } from "ioredis";
|
||||||
|
|
||||||
|
export const createRedisClient = () =>
|
||||||
|
redisEnv.IS_EXTERNAL
|
||||||
|
? new Redis({
|
||||||
|
...defaultRedisOptions,
|
||||||
|
host: redisEnv.HOST,
|
||||||
|
port: redisEnv.PORT,
|
||||||
|
tls: redisEnv.TLS_CA
|
||||||
|
? {
|
||||||
|
ca: redisEnv.TLS_CA,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
username: redisEnv.USERNAME,
|
||||||
|
password: redisEnv.PASSWORD,
|
||||||
|
})
|
||||||
|
: new Redis(defaultRedisOptions);
|
||||||
17
packages/core/src/infrastructure/redis/env.ts
Normal file
17
packages/core/src/infrastructure/redis/env.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { createEnv } from "../env";
|
||||||
|
import { runtimeEnvWithPrefix } from "../env/prefix";
|
||||||
|
import { createBooleanSchema } from "../env/schemas";
|
||||||
|
|
||||||
|
export const redisEnv = createEnv({
|
||||||
|
server: {
|
||||||
|
IS_EXTERNAL: createBooleanSchema(false),
|
||||||
|
HOST: z.string().optional(),
|
||||||
|
PORT: z.coerce.number().default(6379).optional(),
|
||||||
|
TLS_CA: z.string().optional(),
|
||||||
|
USERNAME: z.string().optional(),
|
||||||
|
PASSWORD: z.string().optional(),
|
||||||
|
},
|
||||||
|
runtimeEnv: runtimeEnvWithPrefix("REDIS_"),
|
||||||
|
});
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@trpc/client": "^11.4.3",
|
"@trpc/client": "^11.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { env as commonEnv } from "@homarr/common/env";
|
import { env as commonEnv } from "@homarr/common/env";
|
||||||
import { createEnv } from "@homarr/env";
|
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { env as commonEnv } from "@homarr/common/env";
|
import { env as commonEnv } from "@homarr/common/env";
|
||||||
import { createEnv } from "@homarr/env";
|
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||||
|
|
||||||
const drivers = {
|
const drivers = {
|
||||||
betterSqlite3: "better-sqlite3",
|
betterSqlite3: "better-sqlite3",
|
||||||
|
|||||||
@@ -40,13 +40,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.40.0",
|
"@auth/core": "^0.40.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@testcontainers/mysql": "^11.2.1",
|
"@testcontainers/mysql": "^11.3.1",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"esbuild": "^0.25.6",
|
"esbuild": "^0.25.8",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tsx": "4.20.3",
|
"tsx": "4.20.3",
|
||||||
|
|||||||
@@ -92,19 +92,19 @@ export const integrationDefs = {
|
|||||||
name: "Jellyfin",
|
name: "Jellyfin",
|
||||||
secretKinds: [["username", "password"], ["apiKey"]],
|
secretKinds: [["username", "password"], ["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
||||||
category: ["mediaService"],
|
category: ["mediaService", "mediaRelease"],
|
||||||
},
|
},
|
||||||
emby: {
|
emby: {
|
||||||
name: "Emby",
|
name: "Emby",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
|
||||||
category: ["mediaService"],
|
category: ["mediaService", "mediaRelease"],
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: "Plex",
|
name: "Plex",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
|
||||||
category: ["mediaService"],
|
category: ["mediaService", "mediaRelease"],
|
||||||
},
|
},
|
||||||
jellyseerr: {
|
jellyseerr: {
|
||||||
name: "Jellyseerr",
|
name: "Jellyseerr",
|
||||||
@@ -224,6 +224,7 @@ export const integrationDefs = {
|
|||||||
"downloadClient",
|
"downloadClient",
|
||||||
"healthMonitoring",
|
"healthMonitoring",
|
||||||
"indexerManager",
|
"indexerManager",
|
||||||
|
"mediaRelease",
|
||||||
"mediaRequest",
|
"mediaRequest",
|
||||||
"mediaService",
|
"mediaService",
|
||||||
"mediaTranscoding",
|
"mediaTranscoding",
|
||||||
@@ -282,6 +283,7 @@ export const integrationCategories = [
|
|||||||
"mediaService",
|
"mediaService",
|
||||||
"calendar",
|
"calendar",
|
||||||
"mediaSearch",
|
"mediaSearch",
|
||||||
|
"mediaRelease",
|
||||||
"mediaRequest",
|
"mediaRequest",
|
||||||
"downloadClient",
|
"downloadClient",
|
||||||
"usenet",
|
"usenet",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const widgetKinds = [
|
|||||||
"indexerManager",
|
"indexerManager",
|
||||||
"healthMonitoring",
|
"healthMonitoring",
|
||||||
"releases",
|
"releases",
|
||||||
|
"mediaReleases",
|
||||||
"dockerContainers",
|
"dockerContainers",
|
||||||
"notifications",
|
"notifications",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"dockerode": "^4.0.7"
|
"dockerode": "^4.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createEnv } from "@homarr/env";
|
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||||
import { createBooleanSchema } from "@homarr/env/schemas";
|
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
1
packages/env/index.ts
vendored
1
packages/env/index.ts
vendored
@@ -1 +0,0 @@
|
|||||||
export * from "./src";
|
|
||||||
4
packages/image-proxy/eslint.config.js
Normal file
4
packages/image-proxy/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import baseConfig from "@homarr/eslint-config/base";
|
||||||
|
|
||||||
|
/** @type {import('typescript-eslint').Config} */
|
||||||
|
export default [...baseConfig];
|
||||||
39
packages/image-proxy/package.json
Normal file
39
packages/image-proxy/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@homarr/image-proxy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rm -rf .turbo node_modules",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"prettier": "@homarr/prettier-config",
|
||||||
|
"dependencies": {
|
||||||
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
|
"bcrypt": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"@types/bcrypt": "5.0.2",
|
||||||
|
"eslint": "^9.31.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
133
packages/image-proxy/src/index.ts
Normal file
133
packages/image-proxy/src/index.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
import { createId } from "@homarr/common";
|
||||||
|
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
import { createGetSetChannel } from "@homarr/redis";
|
||||||
|
|
||||||
|
const createHashChannel = (hash: `${string}.${string}`) => createGetSetChannel<string>(`image-proxy:hash:${hash}`);
|
||||||
|
const createUrlByIdChannel = (id: string) =>
|
||||||
|
createGetSetChannel<{
|
||||||
|
url: `${string}.${string}`;
|
||||||
|
headers: `${string}.${string}`;
|
||||||
|
}>(`image-proxy:url:${id}`);
|
||||||
|
const saltChannel = createGetSetChannel<string>("image-proxy:salt");
|
||||||
|
|
||||||
|
export class ImageProxy {
|
||||||
|
private static salt: string | null = null;
|
||||||
|
private async getOrCreateSaltAsync(): Promise<string> {
|
||||||
|
if (ImageProxy.salt) return ImageProxy.salt;
|
||||||
|
const existingSalt = await saltChannel.getAsync();
|
||||||
|
if (existingSalt) {
|
||||||
|
ImageProxy.salt = existingSalt;
|
||||||
|
return existingSalt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
logger.debug(`Generated new salt for image proxy salt="${salt}"`);
|
||||||
|
ImageProxy.salt = salt;
|
||||||
|
await saltChannel.setAsync(salt);
|
||||||
|
return salt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createImageAsync(url: string, headers?: Record<string, string>): Promise<string> {
|
||||||
|
const existingId = await this.getExistingIdAsync(url, headers);
|
||||||
|
if (existingId) {
|
||||||
|
logger.debug(
|
||||||
|
`Image already exists in the proxy id="${existingId}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`,
|
||||||
|
);
|
||||||
|
return this.createImageUrl(existingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = createId();
|
||||||
|
await this.storeImageAsync(id, url, headers);
|
||||||
|
|
||||||
|
return this.createImageUrl(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async forwardImageAsync(id: string): Promise<Blob | null> {
|
||||||
|
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
|
||||||
|
if (!urlAndHeaders) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||||
|
headers: urlAndHeaders.headers ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyUrl = this.createImageUrl(id);
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to fetch image id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl}" statusCode="${response.status}"`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = (await response.blob()) as Blob;
|
||||||
|
logger.debug(
|
||||||
|
`Forwarding image succeeded id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl} size="${(blob.size / 1024).toFixed(1)}KB"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createImageUrl(id: string): string {
|
||||||
|
return `/api/image-proxy/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getImageUrlAndHeadersAsync(id: string) {
|
||||||
|
const urlHeaderChannel = createUrlByIdChannel(id);
|
||||||
|
const urlHeader = await urlHeaderChannel.getAsync();
|
||||||
|
if (!urlHeader) {
|
||||||
|
logger.warn(`Image not found in the proxy id="${id}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: decryptSecret(urlHeader.url),
|
||||||
|
headers: JSON.parse(decryptSecret(urlHeader.headers)) as Record<string, string> | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getExistingIdAsync(url: string, headers: Record<string, string> | undefined): Promise<string | null> {
|
||||||
|
const salt = await this.getOrCreateSaltAsync();
|
||||||
|
const urlHash = await bcrypt.hash(url, salt);
|
||||||
|
const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt);
|
||||||
|
|
||||||
|
const channel = createHashChannel(`${urlHash}.${headerHash}`);
|
||||||
|
return await channel.getAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storeImageAsync(id: string, url: string, headers: Record<string, string> | undefined): Promise<void> {
|
||||||
|
const salt = await this.getOrCreateSaltAsync();
|
||||||
|
const urlHash = await bcrypt.hash(url, salt);
|
||||||
|
const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt);
|
||||||
|
|
||||||
|
const hashChannel = createHashChannel(`${urlHash}.${headerHash}`);
|
||||||
|
const urlHeaderChannel = createUrlByIdChannel(id);
|
||||||
|
await urlHeaderChannel.setAsync({
|
||||||
|
url: encryptSecret(url),
|
||||||
|
headers: encryptSecret(JSON.stringify(headers ?? null)),
|
||||||
|
});
|
||||||
|
await hashChannel.setAsync(id);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Stored image in the proxy id="${id}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private redactUrl(url: string): string {
|
||||||
|
const urlObject = new URL(url);
|
||||||
|
|
||||||
|
const redactedSearch = [...urlObject.searchParams.keys()].map((key) => `${key}=REDACTED`).join("&");
|
||||||
|
|
||||||
|
return `${urlObject.origin}${urlObject.pathname}${redactedSearch ? `?${redactedSearch}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private redactHeaders(headers: Record<string, string> | null): string | null {
|
||||||
|
if (!headers) return null;
|
||||||
|
|
||||||
|
return Object.keys(headers).join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/image-proxy/tsconfig.json
Normal file
9
packages/image-proxy/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"],
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -28,11 +28,12 @@
|
|||||||
"@ctrl/deluge": "^7.1.0",
|
"@ctrl/deluge": "^7.1.0",
|
||||||
"@ctrl/qbittorrent": "^9.6.0",
|
"@ctrl/qbittorrent": "^9.6.0",
|
||||||
"@ctrl/transmission": "^7.2.0",
|
"@ctrl/transmission": "^7.2.0",
|
||||||
"@gitbeaker/rest": "^42.5.0",
|
"@gitbeaker/rest": "^43.3.0",
|
||||||
"@homarr/certificates": "workspace:^0.1.0",
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/image-proxy": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/node-unifi": "^2.6.0",
|
"@homarr/node-unifi": "^2.6.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
import { ResponseError } from "@homarr/common/server";
|
||||||
|
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -10,6 +11,7 @@ import type { TestingResult } from "../base/test-connection/test-connection-serv
|
|||||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||||
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
|
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
|
||||||
|
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
|
||||||
|
|
||||||
const sessionSchema = z.object({
|
const sessionSchema = z.object({
|
||||||
NowPlayingItem: z
|
NowPlayingItem: z
|
||||||
@@ -31,7 +33,34 @@ const sessionSchema = z.object({
|
|||||||
UserName: z.string().nullish(),
|
UserName: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export class EmbyIntegration extends Integration implements IMediaServerIntegration {
|
const itemSchema = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
Taglines: z.array(z.string()),
|
||||||
|
Studios: z.array(z.object({ Name: z.string() })),
|
||||||
|
Overview: z.string().optional(),
|
||||||
|
PremiereDate: z
|
||||||
|
.string()
|
||||||
|
.datetime()
|
||||||
|
.transform((date) => new Date(date))
|
||||||
|
.optional(),
|
||||||
|
DateCreated: z
|
||||||
|
.string()
|
||||||
|
.datetime()
|
||||||
|
.transform((date) => new Date(date)),
|
||||||
|
Genres: z.array(z.string()),
|
||||||
|
CommunityRating: z.number().optional(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
Type: z.string(), // for example "Movie"
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchema = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||||
private static readonly apiKeyHeader = "X-Emby-Token";
|
private static readonly apiKeyHeader = "X-Emby-Token";
|
||||||
private static readonly deviceId = "homarr-emby-integration";
|
private static readonly deviceId = "homarr-emby-integration";
|
||||||
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
|
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
|
||||||
@@ -103,4 +132,69 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||||
|
const limit = 100;
|
||||||
|
const users = await this.fetchUsersPublicAsync();
|
||||||
|
const userId = users.at(0)?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("No users found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = super.getSecretValue("apiKey");
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(
|
||||||
|
super.url(
|
||||||
|
`/Users/${userId}/Items/Latest?Limit=${limit}&Fields=CommunityRating,Studios,PremiereDate,Genres,ChildCount,ProductionYear,DateCreated,Overview,Taglines`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
[EmbyIntegration.apiKeyHeader]: apiKey,
|
||||||
|
Authorization: EmbyIntegration.authorizationHeaderValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = z.array(itemSchema).parse(await response.json());
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: item.Id,
|
||||||
|
type: item.Type === "Movie" ? "movie" : item.Type === "Series" ? "tv" : "unknown",
|
||||||
|
title: item.Name,
|
||||||
|
subtitle: item.Taglines.at(0),
|
||||||
|
description: item.Overview,
|
||||||
|
releaseDate: item.PremiereDate ?? item.DateCreated,
|
||||||
|
imageUrls: {
|
||||||
|
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||||
|
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||||
|
},
|
||||||
|
producer: item.Studios.at(0)?.Name,
|
||||||
|
rating: item.CommunityRating?.toFixed(1),
|
||||||
|
tags: item.Genres,
|
||||||
|
href: super.url(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://dev.emby.media/reference/RestAPI/UserService/getUsersPublic.html
|
||||||
|
private async fetchUsersPublicAsync(): Promise<{ id: string; name: string }[]> {
|
||||||
|
const apiKey = super.getSecretValue("apiKey");
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(super.url("/Users/Public"), {
|
||||||
|
headers: {
|
||||||
|
[EmbyIntegration.apiKeyHeader]: apiKey,
|
||||||
|
Authorization: EmbyIntegration.authorizationHeaderValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
const users = z.array(userSchema).parse(await response.json());
|
||||||
|
|
||||||
|
return users.map((user) => ({
|
||||||
|
id: user.Id,
|
||||||
|
name: user.Name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
packages/integrations/src/interfaces/media-releases.ts
Normal file
76
packages/integrations/src/interfaces/media-releases.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { MantineColor } from "@mantine/core";
|
||||||
|
|
||||||
|
export const mediaTypeConfigurations = {
|
||||||
|
movie: {
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
|
tv: {
|
||||||
|
color: "violet",
|
||||||
|
},
|
||||||
|
music: {
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
color: "orange",
|
||||||
|
},
|
||||||
|
game: {
|
||||||
|
color: "yellow",
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
color: "pink",
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
color: "gray",
|
||||||
|
},
|
||||||
|
} satisfies Record<string, { color: MantineColor }>;
|
||||||
|
|
||||||
|
export type MediaType = keyof typeof mediaTypeConfigurations;
|
||||||
|
|
||||||
|
export interface MediaRelease {
|
||||||
|
id: string;
|
||||||
|
type: MediaType;
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* The subtitle of the media item, if applicable.
|
||||||
|
* Can also contain the season number for TV shows.
|
||||||
|
*/
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
releaseDate: Date;
|
||||||
|
imageUrls: {
|
||||||
|
poster: string | undefined;
|
||||||
|
backdrop: string | undefined;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The name of the studio, publisher or author.
|
||||||
|
*/
|
||||||
|
producer?: string;
|
||||||
|
/**
|
||||||
|
* Price in USD
|
||||||
|
*/
|
||||||
|
price?: number;
|
||||||
|
/**
|
||||||
|
* Rating in any format (e.g. 5/10, 4.5/5, 90%, etc.)
|
||||||
|
*/
|
||||||
|
rating?: string;
|
||||||
|
/**
|
||||||
|
* List of tags / genres / categories
|
||||||
|
*/
|
||||||
|
tags: string[];
|
||||||
|
/**
|
||||||
|
* Link to the media item
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
/*
|
||||||
|
* Video / Music: duration in seconds
|
||||||
|
* Book: number of pages
|
||||||
|
*/
|
||||||
|
length?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMediaReleasesIntegration {
|
||||||
|
getMediaReleasesAsync(): Promise<MediaRelease[]>;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Jellyfin } from "@jellyfin/sdk";
|
|||||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
||||||
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
|
||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
|
|
||||||
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
|
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
|
||||||
@@ -13,9 +15,10 @@ import { Integration } from "../base/integration";
|
|||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||||
|
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
|
||||||
|
|
||||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration {
|
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: "Homarr",
|
name: "Homarr",
|
||||||
@@ -70,6 +73,43 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||||
|
const apiClient = await this.getApiAsync();
|
||||||
|
const userLibraryApi = getUserLibraryApi(apiClient);
|
||||||
|
const userApi = getUserApi(apiClient);
|
||||||
|
|
||||||
|
const users = await userApi.getUsers();
|
||||||
|
const userId = users.data.at(0)?.Id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("No users found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await userLibraryApi.getLatestMedia({
|
||||||
|
fields: ["CustomRating", "Studios", "Genres", "ChildCount", "DateCreated", "Overview", "Taglines"],
|
||||||
|
userId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
return result.data.map((item) => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
id: item.Id!,
|
||||||
|
type: item.Type === "Movie" ? "movie" : item.Type === "Series" ? "tv" : "unknown",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
title: item.Name!,
|
||||||
|
subtitle: item.Taglines?.at(0),
|
||||||
|
description: item.Overview ?? undefined,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
|
||||||
|
imageUrls: {
|
||||||
|
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||||
|
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||||
|
},
|
||||||
|
producer: item.Studios?.at(0)?.Name ?? undefined,
|
||||||
|
rating: item.CommunityRating?.toFixed(1),
|
||||||
|
tags: item.Genres ?? [],
|
||||||
|
href: super.url(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs an ApiClient synchronously with an ApiKey or asynchronously
|
* Constructs an ApiClient synchronously with an ApiKey or asynchronously
|
||||||
* with a username and password.
|
* with a username and password.
|
||||||
|
|||||||
128
packages/integrations/src/mock/data/media-releases.ts
Normal file
128
packages/integrations/src/mock/data/media-releases.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { IMediaReleasesIntegration, MediaRelease } from "../../interfaces/media-releases";
|
||||||
|
|
||||||
|
export class MediaReleasesMockService implements IMediaReleasesIntegration {
|
||||||
|
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||||
|
return await Promise.resolve(mockMediaReleases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockMediaReleases: MediaRelease[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "movie",
|
||||||
|
title: "Inception",
|
||||||
|
subtitle: "A mind-bending thriller",
|
||||||
|
description:
|
||||||
|
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
|
||||||
|
releaseDate: new Date("2010-07-16"),
|
||||||
|
imageUrls: {
|
||||||
|
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||||
|
backdrop: "https://example.com/inception_backdrop.jpg",
|
||||||
|
},
|
||||||
|
producer: "Warner Bros.",
|
||||||
|
price: 14.99,
|
||||||
|
rating: "8.8/10",
|
||||||
|
tags: ["Sci-Fi", "Thriller"],
|
||||||
|
href: "https://example.com/inception",
|
||||||
|
length: 148,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "tv",
|
||||||
|
title: "Breaking Bad",
|
||||||
|
subtitle: "S5E14 - Ozymandias",
|
||||||
|
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
|
||||||
|
releaseDate: new Date("2013-09-15"),
|
||||||
|
imageUrls: {
|
||||||
|
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||||
|
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
|
||||||
|
},
|
||||||
|
producer: "AMC",
|
||||||
|
rating: "9.5/10",
|
||||||
|
tags: ["Crime", "Drama"],
|
||||||
|
href: "https://example.com/breaking_bad",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "music",
|
||||||
|
title: "Random Access Memories",
|
||||||
|
subtitle: "Daft Punk",
|
||||||
|
description: "The fourth studio album by French electronic music duo Daft Punk.",
|
||||||
|
releaseDate: new Date("2013-05-17"),
|
||||||
|
imageUrls: {
|
||||||
|
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||||
|
backdrop: "https://example.com/ram_backdrop.jpg",
|
||||||
|
},
|
||||||
|
producer: "Columbia Records",
|
||||||
|
price: 9.99,
|
||||||
|
rating: "8.5/10",
|
||||||
|
tags: ["Electronic", "Dance", "Pop", "Funk"],
|
||||||
|
href: "https://example.com/ram",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "book",
|
||||||
|
title: "The Great Gatsby",
|
||||||
|
subtitle: "F. Scott Fitzgerald",
|
||||||
|
description: "A novel about the American dream and the disillusionment that comes with it.",
|
||||||
|
releaseDate: new Date("1925-04-10"),
|
||||||
|
imageUrls: {
|
||||||
|
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||||
|
backdrop: "https://example.com/gatsby_backdrop.jpg",
|
||||||
|
},
|
||||||
|
producer: "Scribner",
|
||||||
|
price: 10.99,
|
||||||
|
rating: "4.2/5",
|
||||||
|
tags: ["Classic", "Fiction"],
|
||||||
|
href: "https://example.com/gatsby",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
type: "game",
|
||||||
|
title: "The Legend of Zelda: Breath of the Wild",
|
||||||
|
subtitle: "Nintendo Switch",
|
||||||
|
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
|
||||||
|
releaseDate: new Date("2017-03-03"),
|
||||||
|
imageUrls: {
|
||||||
|
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||||
|
backdrop: "https://example.com/zelda_backdrop.jpg",
|
||||||
|
},
|
||||||
|
producer: "Nintendo",
|
||||||
|
price: 59.99,
|
||||||
|
rating: "10/10",
|
||||||
|
tags: ["Action", "Adventure"],
|
||||||
|
href: "https://example.com/zelda",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
type: "article",
|
||||||
|
title: "The Rise of AI in Healthcare",
|
||||||
|
subtitle: "Tech Innovations",
|
||||||
|
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
|
||||||
|
releaseDate: new Date("2023-10-01"),
|
||||||
|
imageUrls: {
|
||||||
|
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||||
|
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
|
||||||
|
},
|
||||||
|
producer: "Tech Innovations",
|
||||||
|
rating: "4.8/5",
|
||||||
|
tags: ["Technology", "Healthcare"],
|
||||||
|
href: "https://example.com/ai_healthcare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "7",
|
||||||
|
type: "video",
|
||||||
|
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
|
||||||
|
releaseDate: new Date("2024-05-18T17:00:00Z"),
|
||||||
|
imageUrls: {
|
||||||
|
poster:
|
||||||
|
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
||||||
|
backdrop:
|
||||||
|
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
||||||
|
},
|
||||||
|
producer: "PietSmiet",
|
||||||
|
rating: "1K",
|
||||||
|
tags: [],
|
||||||
|
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
ISystemHealthMonitoringIntegration,
|
ISystemHealthMonitoringIntegration,
|
||||||
} from "../interfaces/health-monitoring/health-monitoring-integration";
|
} from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||||
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
|
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
|
||||||
|
import type { IMediaReleasesIntegration } from "../interfaces/media-releases";
|
||||||
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
|
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
|
||||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||||
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||||
@@ -19,6 +20,7 @@ import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monito
|
|||||||
import { DnsHoleMockService } from "./data/dns-hole";
|
import { DnsHoleMockService } from "./data/dns-hole";
|
||||||
import { DownloadClientMockService } from "./data/download";
|
import { DownloadClientMockService } from "./data/download";
|
||||||
import { IndexerManagerMockService } from "./data/indexer-manager";
|
import { IndexerManagerMockService } from "./data/indexer-manager";
|
||||||
|
import { MediaReleasesMockService } from "./data/media-releases";
|
||||||
import { MediaRequestMockService } from "./data/media-request";
|
import { MediaRequestMockService } from "./data/media-request";
|
||||||
import { MediaServerMockService } from "./data/media-server";
|
import { MediaServerMockService } from "./data/media-server";
|
||||||
import { MediaTranscodingMockService } from "./data/media-transcoding";
|
import { MediaTranscodingMockService } from "./data/media-transcoding";
|
||||||
@@ -36,6 +38,7 @@ export class MockIntegration
|
|||||||
IClusterHealthMonitoringIntegration,
|
IClusterHealthMonitoringIntegration,
|
||||||
ISystemHealthMonitoringIntegration,
|
ISystemHealthMonitoringIntegration,
|
||||||
IIndexerManagerIntegration,
|
IIndexerManagerIntegration,
|
||||||
|
IMediaReleasesIntegration,
|
||||||
IMediaRequestIntegration,
|
IMediaRequestIntegration,
|
||||||
IMediaServerIntegration,
|
IMediaServerIntegration,
|
||||||
IMediaTranscodingIntegration,
|
IMediaTranscodingIntegration,
|
||||||
@@ -48,6 +51,7 @@ export class MockIntegration
|
|||||||
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
|
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
|
||||||
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
|
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
|
||||||
private static readonly indexerManager = new IndexerManagerMockService();
|
private static readonly indexerManager = new IndexerManagerMockService();
|
||||||
|
private static readonly mediaReleases = new MediaReleasesMockService();
|
||||||
private static readonly mediaRequest = new MediaRequestMockService();
|
private static readonly mediaRequest = new MediaRequestMockService();
|
||||||
private static readonly mediaServer = new MediaServerMockService();
|
private static readonly mediaServer = new MediaServerMockService();
|
||||||
private static readonly mediaTranscoding = new MediaTranscodingMockService();
|
private static readonly mediaTranscoding = new MediaTranscodingMockService();
|
||||||
@@ -87,6 +91,9 @@ export class MockIntegration
|
|||||||
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
||||||
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
|
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
|
||||||
|
|
||||||
|
// MediaReleasesIntegration
|
||||||
|
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
|
||||||
|
|
||||||
// MediaRequestIntegration
|
// MediaRequestIntegration
|
||||||
getSeriesInformationAsync = MockIntegration.mediaRequest.getSeriesInformationAsync.bind(MockIntegration.mediaRequest);
|
getSeriesInformationAsync = MockIntegration.mediaRequest.getSeriesInformationAsync.bind(MockIntegration.mediaRequest);
|
||||||
requestMediaAsync = MockIntegration.mediaRequest.requestMediaAsync.bind(MockIntegration.mediaRequest);
|
requestMediaAsync = MockIntegration.mediaRequest.requestMediaAsync.bind(MockIntegration.mediaRequest);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { parseStringPromise } from "xml2js";
|
import { parseStringPromise } from "xml2js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
import { ParseError } from "@homarr/common/server";
|
import { ParseError } from "@homarr/common/server";
|
||||||
|
import { ImageProxy } from "@homarr/image-proxy";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
@@ -10,9 +12,10 @@ import { TestConnectionError } from "../base/test-connection/test-connection-err
|
|||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||||
|
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
|
||||||
import type { PlexResponse } from "./interface";
|
import type { PlexResponse } from "./interface";
|
||||||
|
|
||||||
export class PlexIntegration extends Integration implements IMediaServerIntegration {
|
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||||
const token = super.getSecretValue("apiKey");
|
const token = super.getSecretValue("apiKey");
|
||||||
|
|
||||||
@@ -66,6 +69,93 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
return medias;
|
return medias;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||||
|
const token = super.getSecretValue("apiKey");
|
||||||
|
const machineIdentifier = await this.getMachineIdentifierAsync();
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(super.url("/library/recentlyAdded"), {
|
||||||
|
headers: {
|
||||||
|
"X-Plex-Token": token,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await recentlyAddedSchema.parseAsync(await response.json());
|
||||||
|
const imageProxy = new ImageProxy();
|
||||||
|
|
||||||
|
const images =
|
||||||
|
data.MediaContainer.Metadata?.flatMap((item) => [
|
||||||
|
{
|
||||||
|
mediaKey: item.key,
|
||||||
|
type: "poster",
|
||||||
|
url: item.Image.find((image) => image?.type === "coverPoster")?.url,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mediaKey: item.key,
|
||||||
|
type: "backdrop",
|
||||||
|
url: item.Image.find((image) => image?.type === "background")?.url,
|
||||||
|
},
|
||||||
|
]).filter(
|
||||||
|
(image): image is { mediaKey: string; type: "poster" | "backdrop"; url: string } => image.url !== undefined,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const proxiedImages = await Promise.all(
|
||||||
|
images.map(async (image) => {
|
||||||
|
const imageUrl = super.url(image.url as `/${string}`);
|
||||||
|
const proxiedImageUrl = await imageProxy
|
||||||
|
.createImageAsync(imageUrl.toString(), {
|
||||||
|
"X-Plex-Token": token,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.debug(new Error("Failed to proxy image", { cause: error }));
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
mediaKey: image.mediaKey,
|
||||||
|
type: image.type,
|
||||||
|
url: proxiedImageUrl,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
data.MediaContainer.Metadata?.map((item) => {
|
||||||
|
return {
|
||||||
|
id: item.Media.at(0)?.id.toString() ?? item.key,
|
||||||
|
type: item.type === "movie" ? "movie" : item.type === "tv" ? "tv" : "unknown",
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.tagline,
|
||||||
|
description: item.summary,
|
||||||
|
releaseDate: item.originallyAvailableAt
|
||||||
|
? new Date(item.originallyAvailableAt)
|
||||||
|
: new Date(item.addedAt * 1000),
|
||||||
|
imageUrls: {
|
||||||
|
poster: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "poster")?.url,
|
||||||
|
backdrop: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "backdrop")?.url,
|
||||||
|
},
|
||||||
|
producer: item.studio,
|
||||||
|
rating: item.rating?.toFixed(1),
|
||||||
|
tags: item.Genre.map((genre) => genre.tag),
|
||||||
|
href: super
|
||||||
|
.url(`/web/index.html#!/server/${machineIdentifier}/details?key=${encodeURIComponent(item.key)}`)
|
||||||
|
.toString(),
|
||||||
|
length: item.duration ? Math.round(item.duration / 1000) : undefined,
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMachineIdentifierAsync(): Promise<string> {
|
||||||
|
const token = super.getSecretValue("apiKey");
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(super.url("/identity"), {
|
||||||
|
headers: {
|
||||||
|
"X-Plex-Token": token,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await identitySchema.parseAsync(await response.json());
|
||||||
|
return data.MediaContainer.machineIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const token = super.getSecretValue("apiKey");
|
const token = super.getSecretValue("apiKey");
|
||||||
|
|
||||||
@@ -111,3 +201,50 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://plexapi.dev/api-reference/library/get-recently-added
|
||||||
|
const recentlyAddedSchema = z.object({
|
||||||
|
MediaContainer: z.object({
|
||||||
|
Metadata: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string(),
|
||||||
|
studio: z.string().optional(),
|
||||||
|
type: z.string(), // For example "movie"
|
||||||
|
title: z.string(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
addedAt: z.number(),
|
||||||
|
rating: z.number().optional(),
|
||||||
|
tagline: z.string().optional(),
|
||||||
|
originallyAvailableAt: z.string().optional(),
|
||||||
|
Media: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Image: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string(), // for example "coverPoster" or "background"
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
),
|
||||||
|
Genre: z.array(
|
||||||
|
z.object({
|
||||||
|
tag: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://plexapi.dev/api-reference/server/get-server-identity
|
||||||
|
const identitySchema = z.object({
|
||||||
|
MediaContainer: z.object({
|
||||||
|
machineIdentifier: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./base/searchable-integration";
|
|||||||
export * from "./homeassistant/homeassistant-types";
|
export * from "./homeassistant/homeassistant-types";
|
||||||
export * from "./proxmox/proxmox-types";
|
export * from "./proxmox/proxmox-types";
|
||||||
export * from "./unifi-controller/unifi-controller-types";
|
export * from "./unifi-controller/unifi-controller-types";
|
||||||
|
export * from "./interfaces/media-releases";
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"ioredis": "5.6.1",
|
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createEnv } from "@homarr/env";
|
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||||
|
|
||||||
import { logLevels } from "./constants";
|
import { logLevels } from "./constants";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Redis } from "ioredis";
|
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import Transport from "winston-transport";
|
import Transport from "winston-transport";
|
||||||
|
|
||||||
|
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||||
|
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||||
|
|
||||||
const messageSymbol = Symbol.for("message");
|
const messageSymbol = Symbol.for("message");
|
||||||
const levelSymbol = Symbol.for("level");
|
const levelSymbol = Symbol.for("level");
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ const levelSymbol = Symbol.for("level");
|
|||||||
// of the base functionality and `.exceptions.handle()`.
|
// of the base functionality and `.exceptions.handle()`.
|
||||||
//
|
//
|
||||||
export class RedisTransport extends Transport {
|
export class RedisTransport extends Transport {
|
||||||
private redis: Redis | null = null;
|
private redis: RedisClient | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the info to the Redis channel
|
* Log the info to the Redis channel
|
||||||
@@ -21,7 +23,7 @@ export class RedisTransport extends Transport {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||||
this.redis ??= new Redis();
|
this.redis ??= createRedisClient();
|
||||||
|
|
||||||
this.redis
|
this.redis
|
||||||
.publish(
|
.publish(
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
"@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/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/notifications": "^8.1.3",
|
"@mantine/notifications": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.34.0"
|
"@tabler/icons-react": "^3.34.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"adm-zip": "0.5.16",
|
"adm-zip": "0.5.16",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^",
|
"@homarr/common": "workspace:^",
|
||||||
|
"@homarr/core": "workspace:^",
|
||||||
|
"@homarr/db": "workspace:^",
|
||||||
"@homarr/definitions": "workspace:^",
|
"@homarr/definitions": "workspace:^",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"ioredis": "5.6.1",
|
"ioredis": "5.6.1",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Redis } from "ioredis";
|
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||||
|
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Redis connection
|
* Creates a new Redis connection
|
||||||
@@ -7,8 +8,8 @@ import { Redis } from "ioredis";
|
|||||||
export const createRedisConnection = () => {
|
export const createRedisConnection = () => {
|
||||||
if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) {
|
if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) {
|
||||||
// Return null if we are in CI as we don't want to connect to Redis
|
// Return null if we are in CI as we don't want to connect to Redis
|
||||||
return null as unknown as Redis;
|
return null as unknown as RedisClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Redis();
|
return createRedisClient();
|
||||||
};
|
};
|
||||||
|
|||||||
20
packages/request-handler/src/media-release.ts
Normal file
20
packages/request-handler/src/media-release.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
import type { MediaRelease } from "@homarr/integrations/types";
|
||||||
|
|
||||||
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
|
export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
|
MediaRelease[],
|
||||||
|
IntegrationKindByCategory<"mediaRelease">,
|
||||||
|
Record<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration, _input) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getMediaReleasesAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(5, "minutes"),
|
||||||
|
queryKey: "mediaReleases",
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@mantine/dates": "^8.1.3",
|
"@mantine/dates": "^8.1.3",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,9 +36,9 @@
|
|||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@mantine/spotlight": "^8.1.3",
|
"@mantine/spotlight": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.5",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"deepmerge": "4.3.1",
|
"deepmerge": "4.3.1",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"next-intl": "4.3.4",
|
"next-intl": "4.3.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
|
|||||||
@@ -2079,6 +2079,35 @@
|
|||||||
},
|
},
|
||||||
"globalRatio": "Global Ratio"
|
"globalRatio": "Global Ratio"
|
||||||
},
|
},
|
||||||
|
"mediaReleases": {
|
||||||
|
"name": "Media releases",
|
||||||
|
"description": "Display newly added medias or upcoming releases from different integrations",
|
||||||
|
"option": {
|
||||||
|
"layout": {
|
||||||
|
"label": "Layout",
|
||||||
|
"option": {
|
||||||
|
"backdrop": {
|
||||||
|
"label": "Backdrop"
|
||||||
|
},
|
||||||
|
"poster": {
|
||||||
|
"label": "Poster"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"showDescriptionTooltip": {
|
||||||
|
"label": "Show description tooltip"
|
||||||
|
},
|
||||||
|
"showType": {
|
||||||
|
"label": "Show media type badge"
|
||||||
|
},
|
||||||
|
"showSource": {
|
||||||
|
"label": "Show source integration"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"length": {
|
||||||
|
"duration": "{length}min"
|
||||||
|
}
|
||||||
|
},
|
||||||
"mediaRequests-requestList": {
|
"mediaRequests-requestList": {
|
||||||
"name": "Media Requests List",
|
"name": "Media Requests List",
|
||||||
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
|
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
|
||||||
|
|||||||
@@ -33,9 +33,9 @@
|
|||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/dates": "^8.1.3",
|
"@mantine/dates": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"svgson": "^5.3.1"
|
"svgson": "^5.3.1"
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { BadgeProps } from "@mantine/core";
|
import type { BadgeProps, MantineSpacing } from "@mantine/core";
|
||||||
import { ActionIcon, Badge, Group, Popover, Stack } from "@mantine/core";
|
import { Badge, Group, Popover, Stack, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
export function OverflowBadge({
|
export function OverflowBadge({
|
||||||
data,
|
data,
|
||||||
overflowCount = 3,
|
overflowCount = 3,
|
||||||
|
disablePopover = false,
|
||||||
|
groupGap = "xs",
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
data: string[];
|
data: string[];
|
||||||
overflowCount?: number;
|
overflowCount?: number;
|
||||||
|
disablePopover?: boolean;
|
||||||
|
groupGap?: MantineSpacing;
|
||||||
} & BadgeProps) {
|
} & BadgeProps) {
|
||||||
const badgeProps = {
|
const badgeProps = {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
@@ -16,8 +20,8 @@ export function OverflowBadge({
|
|||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Popover width="content" shadow="md">
|
<Popover width="content" shadow="md" disabled={disablePopover}>
|
||||||
<Group gap="xs">
|
<Group gap={groupGap}>
|
||||||
{data.slice(0, overflowCount).map((item) => (
|
{data.slice(0, overflowCount).map((item) => (
|
||||||
<Badge key={item} px="xs" {...badgeProps}>
|
<Badge key={item} px="xs" {...badgeProps}>
|
||||||
{item}
|
{item}
|
||||||
@@ -25,19 +29,11 @@ export function OverflowBadge({
|
|||||||
))}
|
))}
|
||||||
{data.length > overflowCount && (
|
{data.length > overflowCount && (
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon
|
<UnstyledButton display="flex">
|
||||||
{...{
|
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
|
||||||
variant: badgeProps.variant,
|
+{data.length - overflowCount}
|
||||||
color: badgeProps.color,
|
</Badge>
|
||||||
}}
|
</UnstyledButton>
|
||||||
size="sm"
|
|
||||||
fw="bold"
|
|
||||||
fz="sm"
|
|
||||||
p="sm"
|
|
||||||
px="md"
|
|
||||||
>
|
|
||||||
+{data.length - overflowCount}
|
|
||||||
</ActionIcon>
|
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"@mantine/charts": "^8.1.3",
|
"@mantine/charts": "^8.1.3",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"@tiptap/extension-color": "2.26.1",
|
"@tiptap/extension-color": "2.26.1",
|
||||||
"@tiptap/extension-highlight": "2.26.1",
|
"@tiptap/extension-highlight": "2.26.1",
|
||||||
"@tiptap/extension-image": "2.26.1",
|
"@tiptap/extension-image": "2.26.1",
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"mantine-react-table": "2.0.0-beta.9",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.4.1",
|
"next": "15.4.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -509,19 +509,19 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
|
|||||||
alreadyImported: boolean;
|
alreadyImported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerImageSelectorProps {
|
interface ImportRepositorySelectProps {
|
||||||
containerImage: ReleasesRepositoryImport;
|
repository: ReleasesRepositoryImport;
|
||||||
integration?: Integration;
|
integration?: Integration;
|
||||||
versionFilterPrecisionOptions: string[];
|
versionFilterPrecisionOptions: string[];
|
||||||
onImageSelectionChanged?: (isSelected: boolean) => void;
|
onImageSelectionChanged?: (isSelected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerImageSelector = ({
|
const ImportRepositorySelect = ({
|
||||||
containerImage,
|
repository,
|
||||||
integration,
|
integration,
|
||||||
versionFilterPrecisionOptions,
|
versionFilterPrecisionOptions,
|
||||||
onImageSelectionChanged,
|
onImageSelectionChanged,
|
||||||
}: ContainerImageSelectorProps) => {
|
}: ImportRepositorySelectProps) => {
|
||||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||||
const checkBoxProps: CheckboxProps = !onImageSelectionChanged
|
const checkBoxProps: CheckboxProps = !onImageSelectionChanged
|
||||||
? {
|
? {
|
||||||
@@ -539,29 +539,29 @@ const ContainerImageSelector = ({
|
|||||||
label={
|
label={
|
||||||
<Group>
|
<Group>
|
||||||
<Image
|
<Image
|
||||||
src={containerImage.iconUrl}
|
src={repository.iconUrl}
|
||||||
style={{
|
style={{
|
||||||
height: "1.2em",
|
height: "1.2em",
|
||||||
width: "1.2em",
|
width: "1.2em",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text>{containerImage.identifier}</Text>
|
<Text>{repository.identifier}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
{...checkBoxProps}
|
{...checkBoxProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{containerImage.versionFilter && (
|
{repository.versionFilter && (
|
||||||
<Group gap={5}>
|
<Group gap={5}>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{tRepository("versionFilter.label")}:
|
{tRepository("versionFilter.label")}:
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Code>{containerImage.versionFilter.prefix && containerImage.versionFilter.prefix}</Code>
|
<Code>{repository.versionFilter.prefix && repository.versionFilter.prefix}</Code>
|
||||||
<Code color="var(--mantine-primary-color-light)" fw={700}>
|
<Code color="var(--mantine-primary-color-light)" fw={700}>
|
||||||
{versionFilterPrecisionOptions[containerImage.versionFilter.precision]}
|
{versionFilterPrecisionOptions[repository.versionFilter.precision]}
|
||||||
</Code>
|
</Code>
|
||||||
<Code>{containerImage.versionFilter.suffix && containerImage.versionFilter.suffix}</Code>
|
<Code>{repository.versionFilter.suffix && repository.versionFilter.suffix}</Code>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -610,36 +610,47 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
|||||||
enabled: innerProps.isAdmin,
|
enabled: innerProps.isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const containersImages: ReleasesRepositoryImport[] = useMemo(
|
const importRepositories: ReleasesRepositoryImport[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, containerImage) => {
|
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, container) => {
|
||||||
const imageParts = containerImage.image.split("/");
|
const [maybeSource, maybeIdentifierAndVersion] = container.image.split(/\/(.*)/);
|
||||||
const source = imageParts.length > 1 ? imageParts[0] : "docker.io";
|
const hasSource = maybeSource && maybeSource in sourceToProviderKind;
|
||||||
const identifierImage = imageParts.length > 1 ? imageParts[1] : imageParts[0];
|
const source = hasSource ? maybeSource : "docker.io";
|
||||||
|
const identifierAndVersion = hasSource ? maybeIdentifierAndVersion : container.image;
|
||||||
|
|
||||||
if (!source || !identifierImage) return acc;
|
if (!identifierAndVersion) return acc;
|
||||||
|
|
||||||
const providerKey = source in containerImageToProviderKind ? containerImageToProviderKind[source] : "dockerHub";
|
const providerKey = sourceToProviderKind[source];
|
||||||
const integrationId = Object.values(innerProps.integrations).find(
|
const integrationId = Object.values(innerProps.integrations).find(
|
||||||
(integration) => integration.kind === providerKey,
|
(integration) => integration.kind === providerKey,
|
||||||
)?.id;
|
)?.id;
|
||||||
|
|
||||||
const [identifier, version] = identifierImage.split(":");
|
const [identifier, version] = identifierAndVersion.split(":");
|
||||||
|
|
||||||
if (!identifier || !integrationId) return acc;
|
if (!identifier || !integrationId) return acc;
|
||||||
|
|
||||||
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
|
if (
|
||||||
|
acc.some(
|
||||||
|
(item) =>
|
||||||
|
item.providerIntegrationId !== undefined &&
|
||||||
|
innerProps.integrations[item.providerIntegrationId]?.kind === providerKey &&
|
||||||
|
item.identifier === identifier,
|
||||||
|
)
|
||||||
|
)
|
||||||
return acc;
|
return acc;
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
providerIntegrationId: integrationId,
|
providerIntegrationId: integrationId,
|
||||||
identifier,
|
identifier,
|
||||||
iconUrl: containerImage.iconUrl ?? undefined,
|
iconUrl: container.iconUrl ?? undefined,
|
||||||
name: formatIdentifierName(identifier),
|
name: formatIdentifierName(identifier),
|
||||||
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
|
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
|
||||||
alreadyImported: innerProps.repositories.some(
|
alreadyImported: innerProps.repositories.some(
|
||||||
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
|
(item) =>
|
||||||
|
item.providerIntegrationId !== undefined &&
|
||||||
|
innerProps.integrations[item.providerIntegrationId]?.kind === providerKey &&
|
||||||
|
item.identifier === identifier,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return acc;
|
return acc;
|
||||||
@@ -657,13 +668,13 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
|||||||
}, [innerProps, selectedImages, actions]);
|
}, [innerProps, selectedImages, actions]);
|
||||||
|
|
||||||
const allImagesImported = useMemo(
|
const allImagesImported = useMemo(
|
||||||
() => containersImages.every((containerImage) => containerImage.alreadyImported),
|
() => importRepositories.every((repository) => repository.alreadyImported),
|
||||||
[containersImages],
|
[importRepositories],
|
||||||
);
|
);
|
||||||
|
|
||||||
const anyImagesImported = useMemo(
|
const anyImagesImported = useMemo(
|
||||||
() => containersImages.some((containerImage) => containerImage.alreadyImported),
|
() => importRepositories.some((repository) => repository.alreadyImported),
|
||||||
[containersImages],
|
[importRepositories],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -673,7 +684,7 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
|||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
<Title order={3}>{tRepository("importRepositories.loading")}</Title>
|
<Title order={3}>{tRepository("importRepositories.loading")}</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : containersImages.length === 0 ? (
|
) : importRepositories.length === 0 ? (
|
||||||
<Stack justify="center" align="center">
|
<Stack justify="center" align="center">
|
||||||
<IconBrandDocker stroke={1} size={128} />
|
<IconBrandDocker stroke={1} size={128} />
|
||||||
<Title order={3}>{tRepository("importRepositories.noImagesFound")}</Title>
|
<Title order={3}>{tRepository("importRepositories.noImagesFound")}</Title>
|
||||||
@@ -694,23 +705,23 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
|||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{!allImagesImported &&
|
{!allImagesImported &&
|
||||||
containersImages
|
importRepositories
|
||||||
.filter((containerImage) => !containerImage.alreadyImported)
|
.filter((repository) => !repository.alreadyImported)
|
||||||
.map((containerImage) => {
|
.map((repository) => {
|
||||||
const integration = containerImage.providerIntegrationId
|
const integration = repository.providerIntegrationId
|
||||||
? innerProps.integrations[containerImage.providerIntegrationId]
|
? innerProps.integrations[repository.providerIntegrationId]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContainerImageSelector
|
<ImportRepositorySelect
|
||||||
key={containerImage.id}
|
key={repository.id}
|
||||||
containerImage={containerImage}
|
repository={repository}
|
||||||
integration={integration}
|
integration={integration}
|
||||||
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
|
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
|
||||||
onImageSelectionChanged={(isSelected) =>
|
onImageSelectionChanged={(isSelected) =>
|
||||||
isSelected
|
isSelected
|
||||||
? setSelectedImages([...selectedImages, containerImage])
|
? setSelectedImages([...selectedImages, repository])
|
||||||
: setSelectedImages(selectedImages.filter((img) => img !== containerImage))
|
: setSelectedImages(selectedImages.filter((img) => img !== repository))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -723,17 +734,17 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
|||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{anyImagesImported &&
|
{anyImagesImported &&
|
||||||
containersImages
|
importRepositories
|
||||||
.filter((containerImage) => containerImage.alreadyImported)
|
.filter((repository) => repository.alreadyImported)
|
||||||
.map((containerImage) => {
|
.map((repository) => {
|
||||||
const integration = containerImage.providerIntegrationId
|
const integration = repository.providerIntegrationId
|
||||||
? innerProps.integrations[containerImage.providerIntegrationId]
|
? innerProps.integrations[repository.providerIntegrationId]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContainerImageSelector
|
<ImportRepositorySelect
|
||||||
key={containerImage.id}
|
key={repository.id}
|
||||||
containerImage={containerImage}
|
repository={repository}
|
||||||
integration={integration}
|
integration={integration}
|
||||||
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
|
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
|
||||||
/>
|
/>
|
||||||
@@ -763,7 +774,7 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
|||||||
size: "xl",
|
size: "xl",
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerImageToProviderKind: Record<string, IntegrationKind> = {
|
const sourceToProviderKind: Record<string, IntegrationKind> = {
|
||||||
"ghcr.io": "github",
|
"ghcr.io": "github",
|
||||||
"docker.io": "dockerHub",
|
"docker.io": "dockerHub",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import * as healthMonitoring from "./health-monitoring";
|
|||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
import * as indexerManager from "./indexer-manager";
|
import * as indexerManager from "./indexer-manager";
|
||||||
|
import * as mediaReleases from "./media-releases";
|
||||||
import * as mediaRequestsList from "./media-requests/list";
|
import * as mediaRequestsList from "./media-requests/list";
|
||||||
import * as mediaRequestsStats from "./media-requests/stats";
|
import * as mediaRequestsStats from "./media-requests/stats";
|
||||||
import * as mediaServer from "./media-server";
|
import * as mediaServer from "./media-server";
|
||||||
@@ -69,6 +70,7 @@ export const widgetImports = {
|
|||||||
dockerContainers,
|
dockerContainers,
|
||||||
releases,
|
releases,
|
||||||
notifications,
|
notifications,
|
||||||
|
mediaReleases,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
206
packages/widgets/src/media-releases/component.tsx
Normal file
206
packages/widgets/src/media-releases/component.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { Avatar, Badge, Box, Divider, Group, Image, Stack, Text, TooltipFloating, UnstyledButton } from "@mantine/core";
|
||||||
|
import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
|
import type { MediaRelease } from "@homarr/integrations/types";
|
||||||
|
import { mediaTypeConfigurations } from "@homarr/integrations/types";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
import { OverflowBadge } from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
|
||||||
|
const [releases] = clientApi.widget.mediaRelease.getMediaReleases.useSuspenseQuery({
|
||||||
|
integrationIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack p="xs" gap="sm">
|
||||||
|
{releases.map((item, index) => (
|
||||||
|
<Fragment key={item.id}>
|
||||||
|
{index !== 0 && options.layout === "poster" && <Divider />}
|
||||||
|
<Item item={item} options={options} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemProps {
|
||||||
|
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
|
||||||
|
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({ item, options }: ItemProps) => {
|
||||||
|
const locale = useCurrentLocale();
|
||||||
|
const t = useI18n();
|
||||||
|
const length = formatLength(item.length, item.type, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipFloating
|
||||||
|
label={item.description}
|
||||||
|
w={300}
|
||||||
|
multiline
|
||||||
|
disabled={item.description === undefined || item.description.trim() === "" || !options.showDescriptionTooltip}
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
component="a"
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
pos="relative"
|
||||||
|
p={options.layout === "poster" ? 0 : 4}
|
||||||
|
>
|
||||||
|
{options.layout === "backdrop" && (
|
||||||
|
<Box
|
||||||
|
w="100%"
|
||||||
|
h="100%"
|
||||||
|
pos="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${item.imageUrls.backdrop})`,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
opacity: 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Group justify="space-between" h="100%" wrap="nowrap">
|
||||||
|
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
|
||||||
|
{options.layout === "poster" && <Image w={60} src={item.imageUrls.poster} alt={item.title} />}
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw="bold" lineClamp={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{item.subtitle !== undefined && (
|
||||||
|
<Text size="sm" lineClamp={1}>
|
||||||
|
{item.subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Group gap={6} style={{ rowGap: 0 }}>
|
||||||
|
<Info
|
||||||
|
icon={IconCalendar}
|
||||||
|
label={Intl.DateTimeFormat(locale, {
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(item.releaseDate)}
|
||||||
|
/>
|
||||||
|
{length !== undefined && (
|
||||||
|
<>
|
||||||
|
<InfoDivider />
|
||||||
|
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.producer !== undefined && (
|
||||||
|
<>
|
||||||
|
<InfoDivider />
|
||||||
|
<Info label={item.producer} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.rating !== undefined && (
|
||||||
|
<>
|
||||||
|
<InfoDivider />
|
||||||
|
<Info icon={IconStarFilled} label={item.rating} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.price !== undefined && (
|
||||||
|
<>
|
||||||
|
<InfoDivider />
|
||||||
|
<Info label={`$${item.price.toFixed(2)}`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<OverflowBadge
|
||||||
|
size="xs"
|
||||||
|
groupGap={4}
|
||||||
|
data={item.tags}
|
||||||
|
overflowCount={3}
|
||||||
|
disablePopover
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{(options.showType || options.showSource) && (
|
||||||
|
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
|
||||||
|
{options.showType && (
|
||||||
|
<Badge
|
||||||
|
w="max-content"
|
||||||
|
size="xs"
|
||||||
|
color={mediaTypeConfigurations[item.type].color}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{item.type}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{options.showSource && (
|
||||||
|
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</TooltipFloating>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IconAndLabelProps {
|
||||||
|
icon?: TablerIcon;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoDivider = () => (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
|
||||||
|
return (
|
||||||
|
<Group gap={4}>
|
||||||
|
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} />}
|
||||||
|
<Text size="xs" c="gray.5">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLength = (length: number | undefined, type: MediaRelease["type"], t: TranslationFunction) => {
|
||||||
|
if (!length) return undefined;
|
||||||
|
if (type === "movie" || type === "tv" || type === "video" || type === "music" || type === "article") {
|
||||||
|
return {
|
||||||
|
type: "duration" as const,
|
||||||
|
label: t("widget.mediaReleases.length.duration", {
|
||||||
|
length: Math.round(length / 60).toString(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "book") {
|
||||||
|
return {
|
||||||
|
type: "page" as const,
|
||||||
|
label: length.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
35
packages/widgets/src/media-releases/index.ts
Normal file
35
packages/widgets/src/media-releases/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { IconTicket } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
|
||||||
|
icon: IconTicket,
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from((factory) => ({
|
||||||
|
layout: factory.select({
|
||||||
|
defaultValue: "backdrop",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "backdrop",
|
||||||
|
label: (t) => t("widget.mediaReleases.option.layout.option.backdrop.label"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "poster",
|
||||||
|
label: (t) => t("widget.mediaReleases.option.layout.option.poster.label"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
showDescriptionTooltip: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
|
showType: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
|
showSource: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
supportedIntegrations: ["mock", "emby", "jellyfin", "plex"],
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
731
pnpm-lock.yaml
generated
731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,8 +25,15 @@ envsubst '${HOSTNAME}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
|
|||||||
nginx -g 'daemon off;' &
|
nginx -g 'daemon off;' &
|
||||||
NGINX_PID=$!
|
NGINX_PID=$!
|
||||||
|
|
||||||
redis-server /app/redis.conf &
|
if [ $REDIS_IS_EXTERNAL = "true" ]; then
|
||||||
REDIS_PID=$!
|
echo "Using external Redis server at redis://$REDIS_HOST:$REDIS_PORT"
|
||||||
|
else
|
||||||
|
echo "Starting internal Redis server"
|
||||||
|
redis-server /app/redis.conf &
|
||||||
|
REDIS_PID=$!
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
node apps/tasks/tasks.cjs &
|
node apps/tasks/tasks.cjs &
|
||||||
TASKS_PID=$!
|
TASKS_PID=$!
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.4.1",
|
"@next/eslint-plugin-next": "15.4.2",
|
||||||
"eslint-config-prettier": "^10.1.7",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-config-turbo": "^2.5.5",
|
"eslint-config-turbo": "^2.5.5",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"prettier-plugin-packagejson": "^2.5.18",
|
"prettier-plugin-packagejson": "^2.5.19",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user