Compare commits

..
Author SHA1 Message Date
ajnart f7dae52b7e style: auto-fix lint and format 2026-06-01 15:43:07 +00:00
Thomas Camlong 0f3eabfb11 perf(integrations): speed up mediaRequestList Overseerr fetches
Reuse a shared TLS agent with timeouts, parallelize list calls, dedupe
detail fetches, and cap concurrency to reduce cron job duration.
2026-06-01 17:41:53 +02:00
Thomas Camlong b2b22aace0 feat(i18n): add media mode help text and request modal status translations
- Add search.mode.media.help for the Modes list entry
- Add table header 'Status' and status badge 'Requested' for the
  request modal season table
2026-05-30 00:56:39 +02:00
Thomas Camlong 3e856c648f feat(media-request): add availability indicators and allow TV re-requests
- Extend searchSchema to parse mediaInfo.status and map to
  MediaAvailability (available, partial, processing, requested)
- Extend mediaInformationSchema to parse already-requested seasons
  from mediaInfo.requests[].seasons[].seasonNumber
- Show colored availability badge on search results in spotlight
- Allow re-requesting TV shows (hide Request only for fully available
  movies), so users can request additional seasons
2026-05-30 00:56:32 +02:00
Thomas Camlong 8a0c438a7d fix(spotlight): add media mode to Modes list and remove from search results
Media request search now appears in the Modes section (with no keyboard
shortcut) instead of being mixed into search results when typing. Changed
keyPath from 'character' to 'modeKey' to support modes without a trigger
character. Removed the mediaRequestSearchEntry from home search results
since it is now accessible via the Modes list.
2026-05-30 00:56:24 +02:00
Thomas Camlong 5a697f911a fix(api): move getMediaRequestOptions and requestMedia to integration router
The procedures were on the searchEngine router which only supported
jellyseerr and overseerr kinds. Moved to the integration router with
seerr added as a supported kind, fixing NOT_FOUND errors for seerr
integrations. Updated RequestMediaModal to use the new endpoint paths.
2026-05-30 00:56:15 +02:00
Thomas Camlong d4279a938d wip on fixing seerr media request 2026-05-29 23:42:39 +02:00
85 changed files with 2147 additions and 2228 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--container-architecture linux/amd64
+1 -4
View File
@@ -19,10 +19,7 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information
description: | description: Add any other information related to the integration.
Add any other information related to the integration.
After your integration is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
- type: dropdown - type: dropdown
attributes: attributes:
label: Public API available? label: Public API available?
+1 -4
View File
@@ -19,10 +19,7 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information
description: | description: Add any other information related to the widget.
Add any other information related to the widget.
After your widget is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
- type: dropdown - type: dropdown
attributes: attributes:
label: Are you willing to contribute this yourself? label: Are you willing to contribute this yourself?
-131
View File
@@ -1,131 +0,0 @@
name: Update CODEOWNERS
on:
workflow_dispatch: {}
pull_request:
paths:
- CODEOWNERS
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/router/widgets/**
- packages/cron-jobs/src/jobs/**
- scripts/generate-codeowners.mts
- scripts/validate-codeowners.mts
- scripts/codeowners-overrides.json
push:
paths:
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/router/widgets/**
- packages/cron-jobs/src/jobs/**
- scripts/generate-codeowners.mts
- scripts/validate-codeowners.mts
- scripts/codeowners-overrides.json
branches:
- dev
permissions:
contents: read
pull-requests: write
jobs:
validate:
name: Validate CODEOWNERS
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check CODEOWNERS is up to date
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
- name: Validate CODEOWNERS
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: pnpm exec tsx ./scripts/validate-codeowners.mts
update:
name: Open CODEOWNERS update PR
needs: validate
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: update-codeowners
cancel-in-progress: false
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write
permission-pull-requests: write
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate CODEOWNERS
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: pnpm scripts:generate-codeowners
- name: Validate CODEOWNERS
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: pnpm exec tsx ./scripts/validate-codeowners.mts
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.obtainToken.outputs.token }}
branch: update-codeowners
base: dev
title: "chore: update widget/integration CODEOWNERS"
delete-branch: true
commit-message: "chore: update widget/integration CODEOWNERS"
body: |
This PR updates per-widget and per-integration CODEOWNERS entries from git history.
Unmapped authors can be added in `scripts/codeowners-overrides.json`.
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
if: steps.create-pull-request.outputs.pull-request-number != ''
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash
-83
View File
@@ -11,86 +11,3 @@ pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
# BEGIN GENERATED widget/integration owners
/packages/api/src/router/widgets/anchor-notes.ts /packages/integrations/src/anchor/ /packages/widgets/src/anchor-note/ @omariqbal
/packages/api/src/router/widgets/app.ts /packages/widgets/src/app/ @manuel-rw
/packages/api/src/router/widgets/calendar.ts /packages/widgets/src/calendar/ @manuel-rw
/packages/api/src/router/widgets/coolify.ts /packages/integrations/src/coolify/ /packages/widgets/src/coolify/ @Rhiz3K
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/controls/ @manuel-rw
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/summary/ @manuel-rw
/packages/api/src/router/widgets/downloads.ts /packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/widgets/src/downloads/ @manuel-rw
/packages/api/src/router/widgets/firewall.ts /packages/cron-jobs/src/jobs/integrations/firewall.ts /packages/widgets/src/firewall/ @Oupsman
/packages/api/src/router/widgets/health-monitoring.ts /packages/cron-jobs/src/jobs/integrations/health-monitoring.ts /packages/widgets/src/health-monitoring/ @dgshue
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/album-carousel/ @manuel-rw
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/server-stats/ @manuel-rw
/packages/api/src/router/widgets/indexer-manager.ts /packages/cron-jobs/src/jobs/integrations/indexer-manager.ts /packages/widgets/src/indexer-manager/ @manuel-rw
/packages/api/src/router/widgets/media-release.ts /packages/widgets/src/media-releases/ @manuel-rw
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/list/ @manuel-rw
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/stats/ @manuel-rw
/packages/api/src/router/widgets/media-server.ts /packages/cron-jobs/src/jobs/integrations/media-server.ts /packages/widgets/src/media-server/ @manuel-rw
/packages/api/src/router/widgets/media-transcoding.ts /packages/cron-jobs/src/jobs/integrations/media-transcoding.ts /packages/integrations/src/media-transcoding/ /packages/widgets/src/media-transcoding/ @manuel-rw
/packages/api/src/router/widgets/minecraft.ts /packages/widgets/src/minecraft/server-status/ @manuel-rw
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/network-status/ @pitschi
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/summary/ @pitschi
/packages/api/src/router/widgets/notebook.ts /packages/widgets/src/notebook/ @manuel-rw
/packages/api/src/router/widgets/notifications.ts /packages/cron-jobs/src/jobs/integrations/notifications.ts /packages/widgets/src/notifications/ @itzTheMeow
/packages/api/src/router/widgets/releases.ts /packages/widgets/src/releases/ @Aandree5
/packages/api/src/router/widgets/rssFeed.ts /packages/widgets/src/rssFeed/ @manuel-rw
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/entity-state/ @manuel-rw
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/execute-automation/ @manuel-rw
/packages/api/src/router/widgets/speedtest-tracker.ts /packages/integrations/src/speedtest-tracker/ /packages/widgets/src/speedtest-tracker/ @kylegrieder
/packages/api/src/router/widgets/stocks.ts /packages/widgets/src/stocks/ @jackwellerreal
/packages/api/src/router/widgets/timetable.ts /packages/cron-jobs/src/jobs/integrations/timetable.ts /packages/widgets/src/timetable/ @manuel-rw
/packages/api/src/router/widgets/tracearr.ts /packages/cron-jobs/src/jobs/integrations/tracearr.ts /packages/integrations/src/tracearr/ /packages/widgets/src/tracearr/ @StrandedTurtle
/packages/api/src/router/widgets/umami.ts /packages/cron-jobs/src/jobs/integrations/umami.ts /packages/integrations/src/umami/ /packages/widgets/src/umami/ @febrile42
/packages/api/src/router/widgets/weather.ts /packages/cron-jobs/src/jobs/weather.ts /packages/widgets/src/weather/ @manuel-rw
/packages/cron-jobs/src/jobs/docker.ts /packages/widgets/src/docker/ @ashtonbatty
/packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/integrations/src/pi-hole/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/aria2/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/deluge/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/nzbget/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/qbittorrent/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/sabnzbd/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/slskd/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/transmission/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/integrations/src/homeassistant/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/lidarr/ @NicolasNewman
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/radarr/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/readarr/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/sonarr/ @manuel-rw
/packages/integrations/src/adguard-home/ @manuel-rw
/packages/integrations/src/codeberg/ @Aandree5
/packages/integrations/src/dashdot/ @dgshue
/packages/integrations/src/docker-hub/ @Aandree5
/packages/integrations/src/emby/ @manuel-rw
/packages/integrations/src/github-container-registry/ @manuel-rw
/packages/integrations/src/github/ @manuel-rw
/packages/integrations/src/gitlab/ @Aandree5
/packages/integrations/src/glances/ @dgshue
/packages/integrations/src/ical/ @NicolasNewman
/packages/integrations/src/jellyfin/ @manuel-rw
/packages/integrations/src/jellyseerr/ @manuel-rw
/packages/integrations/src/linuxserverio/ @Aandree5
/packages/integrations/src/nextcloud/ @manuel-rw
/packages/integrations/src/npm/ @Aandree5
/packages/integrations/src/ntfy/ @manuel-rw
/packages/integrations/src/openmediavault/ @dgshue
/packages/integrations/src/opnsense/ @manuel-rw
/packages/integrations/src/overseerr/ @manuel-rw
/packages/integrations/src/plex/ @manuel-rw
/packages/integrations/src/prowlarr/ @manuel-rw
/packages/integrations/src/proxmox/ @manuel-rw
/packages/integrations/src/quay/ @Aandree5
/packages/integrations/src/search-ch/ @manuel-rw
/packages/integrations/src/seerr/ @manuel-rw
/packages/integrations/src/truenas/ @dgshue
/packages/integrations/src/unifi-controller/ @manuel-rw
/packages/integrations/src/unraid/ @dgshue
/packages/widgets/src/bookmarks/ @manuel-rw
/packages/widgets/src/clock/ @manuel-rw
/packages/widgets/src/iframe/ @manuel-rw
/packages/widgets/src/system-disks/ @manuel-rw
/packages/widgets/src/system-resources/ @kylegrieder
/packages/widgets/src/video/ @manuel-rw
# END GENERATED widget/integration owners
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import { DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -18,12 +18,7 @@ export const CustomMantineProvider = ({
const manager = useColorSchemeManager(); const manager = useColorSchemeManager();
return ( return (
<DirectionProvider> <DirectionProvider>
<MantineProvider <MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children} {children}
</MantineProvider> </MantineProvider>
</DirectionProvider> </DirectionProvider>
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
@@ -33,12 +33,7 @@ export const BoardMantineProvider = ({
}); });
return ( return (
<MantineProvider <MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
{children} {children}
</MantineProvider> </MantineProvider>
); );
@@ -1,15 +1,15 @@
"use client"; "use client";
import { Button, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userInitSchema } from "@homarr/validation/user"; import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => { export const InitUserForm = () => {
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} /> <TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields <CustomPasswordInput
passwordInputProps={form.getInputProps("password")} withPasswordRequirements
confirmPasswordInputProps={form.getInputProps("confirmPassword")} label={t("field.password.label")}
{...form.getInputProps("password")}
/> />
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}> <Button type="submit" fullWidth loading={isPending}>
{t("action.create")} {t("action.create")}
</Button> </Button>
@@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
); );
}; };
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); const integrationCategories = integrationDefs[searchParams.kind].category.flat();
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -107,23 +107,6 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
const relativeTime = useTimeAgo(data.timestamp); const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data: data.containers, data: data.containers,
enableDensityToggle: false, enableDensityToggle: false,
@@ -142,11 +125,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
}, },
initialState: { density: "xs", showGlobalFilter: true }, initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => ( renderTopToolbarCustomActions: () => {
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}> const utils = clientApi.useUtils();
{tDocker("action.refresh.label")} const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
</Button> async onSuccess() {
), await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
return (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isPending}
>
{tDocker("action.refresh.label")}
</Button>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => { renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original); const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return ( return (
@@ -1,7 +1,20 @@
"use client"; "use client";
import { startTransition, useCallback, useState } from "react"; import { startTransition, useCallback, useState } from "react";
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core"; import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks"; import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react"; import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -11,11 +24,10 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form"; import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user"; import { userPasswordSchema } from "@homarr/validation/user";
@@ -155,10 +167,18 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<form> <form>
<Card p="xl"> <Card p="xl">
<Stack gap="md"> <Stack gap="md">
<UserCreatePasswordFields <CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
variant="filled" variant="filled"
passwordInputProps={securityForm.getInputProps("password")} withAsterisk
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")} {...securityForm.getInputProps("password")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
/> />
</Stack> </Stack>
</Card> </Card>
+2 -2
View File
@@ -6,9 +6,9 @@
}, },
"engines": { "engines": {
"node": ">=24.16.0", "node": ">=24.16.0",
"pnpm": ">=10.34.1" "pnpm": ">=10.33.4"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@10.33.4",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"better-sqlite3" "better-sqlite3"
+6 -8
View File
@@ -25,8 +25,6 @@
"release": "semantic-release", "release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"scripts:generate-codeowners": "tsx ./scripts/generate-codeowners.mts",
"scripts:validate-codeowners": "tsx ./scripts/validate-codeowners.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"", "start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ", "test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
@@ -62,9 +60,9 @@
}, },
"engines": { "engines": {
"node": ">=24.16.0", "node": ">=24.16.0",
"pnpm": ">=10.34.1" "pnpm": ">=10.33.4"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@10.33.4",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml", "@tree-sitter-grammars/tree-sitter-yaml",
@@ -78,14 +76,14 @@
"tree-sitter-json" "tree-sitter-json"
], ],
"overrides": { "overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7", "@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.7", "@babel/runtime@<7.26.10": ">=7.29.2",
"axios@>=1.0.0 <1.8.2": ">=1.16.1", "axios@>=1.0.0 <1.8.2": ">=1.16.1",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6", "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6", "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0", "esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5", "form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.23", "hono@<4.6.5": ">=4.12.22",
"linkifyjs@<4.3.2": ">=4.3.3", "linkifyjs@<4.3.2": ">=4.3.3",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
@@ -95,7 +93,7 @@
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7", "tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14" "vite@>=5.0.0 <=5.4.18": ">=8.0.14"
}, },
"patchedDependencies": { "patchedDependencies": {
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories, integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema, integrationSavePermissionsSchema,
integrationUpdateSchema, integrationUpdateSchema,
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
@@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
import { mapTestConnectionError } from "./map-test-connection-error"; import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
url: input.url, url: input.url,
}); });
if ( // Media request search is a first-class Spotlight action. Do not create a search engine for it.
input.attemptSearchEngineCreation &&
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
}), }),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
}); });
return { return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => { inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
users: userPermissions users: userPermissions
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
user, user,
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}), }),
groups: dbGroupIntegrationPermission groups: dbGroupIntegrationPermission
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
}, },
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
}; };
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query)); return await integrationInstance.searchAsync(encodeURI(input.query));
}), }),
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
searchMediaRequests: protectedProcedure
.input(
z.object({
query: z.string(),
integrationIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const query = input.query.trim();
if (query.length === 0) return [];
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
const results = await Promise.all(
integrationsWithAccess.map(async (integration) => {
const integrationInstance = await createIntegrationAsync({
id: integration.id,
name: integration.name,
url: integration.url,
kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
externalUrl: integration.app?.href ?? null,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
const permissions = constructIntegrationPermissions(integration, ctx.session);
return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
searchResults.map((result) => ({
...result,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
},
})),
);
}),
);
return results.flat();
}),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
}); });
interface IntegrationRouterContext {
db: Database;
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
}
const getAccessibleMediaRequestSearchIntegrationsAsync = async (
ctx: IntegrationRouterContext,
integrationIds?: string[],
) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
where:
integrationIds && integrationIds.length > 0
? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
: inArray(integrations.kind, mediaRequestSearchKinds),
orderBy: asc(integrations.name),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "One or more media request search integrations were not found",
});
}
const integrationsWithAccess = await Promise.all(
integrationsFromDb.map(async (integration) => ({
integration,
hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
})),
);
if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query one or more media request search integrations",
});
}
return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
};
interface UpdateSecretInput { interface UpdateSecretInput {
integrationId: string; integrationId: string;
value: string; value: string;
@@ -640,35 +770,6 @@ interface AddSecretInput {
kind: IntegrationSecretKind; kind: IntegrationSecretKind;
} }
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" }); const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure create: permissionRequiredProcedure
.requiresPermission("search-engine-create") .requiresPermission("search-engine-create")
.input(searchEngineManageSchema) .input(searchEngineManageSchema)
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; import { encryptSecret } from "@homarr/common/server";
import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; import type { GroupPermissionKey } from "@homarr/definitions";
@@ -59,6 +59,127 @@ describe("all should return all integrations", () => {
}); });
}); });
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
test("with integration use access should only return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
},
{
id: "2",
name: "Jellyfin",
kind: "jellyfin",
url: "http://jellyfin.local",
},
]);
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.kind).toBe("jellyseerr");
});
test("without access should not return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(0);
});
test("with direct integration use access should return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
});
});
describe("searchMediaRequests should validate scoped integrations", () => {
test("with a non media search integration should throw not found", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyfin",
kind: "jellyfin",
url: "http://jellyfin.local",
});
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
"One or more media request search integrations were not found",
);
});
test("without query access to a scoped media search integration should throw forbidden", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
"User does not have permission to query one or more media request search integrations",
);
});
});
describe("byId should return an integration by id", () => { describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => { test("with full access should return an integration by id", async () => {
const db = createDb(); const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
}); });
test("with create integration access should create a new integration when creating search engine", async () => { test("with create integration access should not create a search engine for media request search integrations", async () => {
const db = createDb(); const db = createDb();
const caller = integrationRouter.createCaller({ const caller = integrationRouter.createCaller({
db, db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); expect(dbSearchEngine).toBeUndefined();
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
);
}); });
test("with create integration access should create a new integration with new linked app", async () => { test("with create integration access should create a new integration with new linked app", async () => {
+8 -50
View File
@@ -81,7 +81,13 @@ describe("initUser should initialize the first user", () => {
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch"); await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
}); });
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => { it.each([
["aB2%"], // too short
["abc123DEF"], // does not contain special characters
["abcDEFghi+"], // does not contain numbers
["ABC123+/-"], // does not contain lowercase
["abc123+/-"], // does not contain uppercase
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
const db = createDb(); const db = createDb();
await createOnboardingStepAsync(db, "user"); await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({ const caller = userRouter.createCaller({
@@ -97,55 +103,7 @@ describe("initUser should initialize the first user", () => {
confirmPassword: password, confirmPassword: password,
}); });
await expect(actAsync()).rejects.toThrow(); await expect(actAsync()).rejects.toThrow("passwordRequirements");
});
it("should accept passwords without complexity requirements", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "abc123DEF",
confirmPassword: "abc123DEF",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
});
it("should accept passwords with special characters like LoveHomarr<3", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "LoveHomarr<3",
confirmPassword: "LoveHomarr<3",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
}); });
}); });
@@ -14,7 +14,9 @@ export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MIN
}), }),
); );
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback( export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE, {
expectedMaximumDurationInMillis: 15_000,
}).withCallback(
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, { createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
widgetKinds: ["mediaRequests-requestList"], widgetKinds: ["mediaRequests-requestList"],
getInput: { getInput: {
-2
View File
@@ -5,5 +5,3 @@ export * from "./icon-picker/icon-picker";
export * from "./new-app/icon-matcher"; export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media"; export * from "./upload-media/upload-media";
export * from "./user-create/user-create-password-fields";
@@ -1,33 +0,0 @@
"use client";
import { PasswordInput, Stack } from "@mantine/core";
import type { PasswordInputProps } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
interface UserCreatePasswordFieldsProps {
passwordInputProps: PasswordInputProps;
confirmPasswordInputProps: PasswordInputProps;
variant?: PasswordInputProps["variant"];
}
export const UserCreatePasswordFields = ({
passwordInputProps,
confirmPasswordInputProps,
variant,
}: UserCreatePasswordFieldsProps) => {
const t = useScopedI18n("user.field");
return (
<Stack gap="md">
<CustomPasswordInput
withPasswordRequirements
label={t("password.label")}
variant={variant}
{...passwordInputProps}
/>
<PasswordInput label={t("passwordConfirm.label")} variant={variant} {...confirmPasswordInputProps} />
</Stack>
);
};
@@ -12,12 +12,14 @@ interface SeriesInformation {
overview: string; overview: string;
seasons: SerieSeason[]; seasons: SerieSeason[];
posterPath: string; posterPath: string;
requestedSeasons: number[];
} }
interface MovieInformation { interface MovieInformation {
id: number; id: number;
overview: string; overview: string;
posterPath: string; posterPath: string;
requestedSeasons: number[];
} }
export type MediaInformation = SeriesInformation | MovieInformation; export type MediaInformation = SeriesInformation | MovieInformation;
@@ -1,6 +1,10 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { RequestInit, Response } from "undici";
import { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { splitToChunksWithNItems } from "@homarr/common";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -24,6 +28,14 @@ import {
const logger = createLogger({ module: "overseerrIntegration" }); const logger = createLogger({ module: "overseerrIntegration" });
const REQUEST_TIMEOUT_MS = 10_000;
const DETAIL_FETCH_BATCH_SIZE = 5;
interface OverseerrFetchClient {
fetchAsync: (url: URL, init?: RequestInit) => Promise<Response>;
fetchUncheckedAsync: (url: URL, init?: RequestInit) => Promise<Response>;
}
interface OverseerrSearchResult { interface OverseerrSearchResult {
id: number; id: number;
name: string; name: string;
@@ -40,12 +52,43 @@ export class OverseerrIntegration
extends Integration extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult> implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{ {
public async searchAsync(query: string) { private async createFetchClientAsync(): Promise<OverseerrFetchClient> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), { const agent = await createCertificateAgentAsync(undefined);
headers: { const apiKey = this.getSecretValue("apiKey");
"X-Api-Key": this.getSecretValue("apiKey"),
const requestAsync = async (url: URL, init?: RequestInit) => {
return await withTimeoutAsync(
(signal) =>
fetch(url, {
...init,
signal,
dispatcher: agent,
headers: {
"X-Api-Key": apiKey,
...init?.headers,
},
}),
REQUEST_TIMEOUT_MS,
);
};
return {
fetchAsync: async (url, init) => {
const response = await requestAsync(url, init);
if (!response.ok) {
throw new Error(`Overseerr request failed: ${response.status} ${response.statusText} (${url.toString()})`);
}
return response;
}, },
}); fetchUncheckedAsync: requestAsync,
};
}
public async searchAsync(query: string) {
const client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/search", { query }));
const schemaData = await searchSchema.parseAsync(await response.json()); const schemaData = await searchSchema.parseAsync(await response.json());
if (!schemaData.results) { if (!schemaData.results) {
@@ -60,17 +103,22 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined, text: "overview" in result ? result.overview : undefined,
type: result.mediaType, type: result.mediaType,
inLibrary: result.mediaInfo !== undefined, inLibrary: result.mediaInfo !== undefined,
availability: result.mediaInfo
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
: undefined,
})); }));
} }
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) { public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const client = await this.createFetchClientAsync();
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`); const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await fetchWithTrustedCertificatesAsync(url, { const response = await client.fetchAsync(url);
headers: { const data = await mediaInformationSchema.parseAsync(await response.json());
"X-Api-Key": this.getSecretValue("apiKey"), const requestedSeasons = [
}, ...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
}); ];
return await mediaInformationSchema.parseAsync(await response.json()); const { mediaInfo: _strip, ...rest } = data;
return { ...rest, requestedSeasons };
} }
/** /**
@@ -80,8 +128,9 @@ export class OverseerrIntegration
* @param seasons A list of the seasons that should be requested. * @param seasons A list of the seasons that should be requested.
*/ */
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> { public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
const client = await this.createFetchClientAsync();
const url = this.url("/api/v1/request"); const url = this.url("/api/v1/request");
const response = await fetchWithTrustedCertificatesAsync(url, { const response = await client.fetchUncheckedAsync(url, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
mediaType, mediaType,
@@ -89,7 +138,6 @@ export class OverseerrIntegration
seasons, seasons,
}), }),
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@@ -115,25 +163,16 @@ export class OverseerrIntegration
} }
public async getRequestsAsync(): Promise<MediaRequest[]> { public async getRequestsAsync(): Promise<MediaRequest[]> {
const pendingRequests = await fetchWithTrustedCertificatesAsync( const client = await this.createFetchClientAsync();
this.url("/api/v1/request", { take: 20, filter: "pending" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), { const [pendingResponse, allResponse] = await Promise.all([
headers: { client.fetchAsync(this.url("/api/v1/request", { take: 20, filter: "pending" })),
"X-Api-Key": this.getSecretValue("apiKey"), client.fetchAsync(this.url("/api/v1/request", { take: 20 })),
}, ]);
});
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results; const pendingResults = (await getRequestsSchema.parseAsync(await pendingResponse.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results; const allResults = (await getRequestsSchema.parseAsync(await allResponse.json())).results;
//Concat the 2 lists while remove any duplicate pending from the all items list
let requests; let requests;
if (pendingResults.length > 0 && allResults.length > 0) { if (pendingResults.length > 0 && allResults.length > 0) {
@@ -142,37 +181,53 @@ export class OverseerrIntegration
); );
} else if (pendingResults.length > 0) requests = pendingResults; } else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults; else if (allResults.length > 0) requests = allResults;
else return Promise.all([]); else return [];
return await Promise.all( const uniqueKeys = [...new Set(requests.map((request) => `${request.type}:${request.media.tmdbId}`))];
requests.map(async (request): Promise<MediaRequest> => { const informationByKey = new Map<string, MediaInformation>();
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40 for (const batch of splitToChunksWithNItems(uniqueKeys, DETAIL_FETCH_BATCH_SIZE)) {
const inProgress = (request.media.downloadStatus ?? []).length >= 1; const batchResults = await Promise.all(
batch.map(async (key) => {
const separatorIndex = key.indexOf(":");
const type = key.slice(0, separatorIndex) as MediaRequest["type"];
const tmdbId = Number(key.slice(separatorIndex + 1));
const information = await this.getItemInformationAsync(client, type, tmdbId);
return [key, information] as const;
}),
);
return { for (const [key, information] of batchResults) {
id: request.id, informationByKey.set(key, information);
name: information.name, }
status: this.mapRequestStatus(request.status), }
availability: this.mapAvailability(request.media.status, inProgress),
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`, return requests.map((request): MediaRequest => {
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`, const information = informationByKey.get(`${request.type}:${request.media.tmdbId}`)!;
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type, const inProgress = (request.media.downloadStatus ?? []).length >= 1;
createdAt: request.createdAt,
airDate: new Date(information.airDate), return {
requestedBy: request.requestedBy id: request.id,
? ({ name: information.name,
...request.requestedBy, status: this.mapRequestStatus(request.status),
displayName: request.requestedBy.displayName, availability: this.mapAvailability(request.media.status, inProgress),
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(), backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(), posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
} satisfies Omit<RequestUser, "requestCount">) href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
: undefined, type: request.type,
}; createdAt: request.createdAt,
}), airDate: new Date(information.airDate),
); requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
});
} }
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -212,20 +267,14 @@ export class OverseerrIntegration
} }
public async getStatsAsync(): Promise<RequestStats> { public async getStatsAsync(): Promise<RequestStats> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), { const client = await this.createFetchClientAsync();
headers: { const response = await client.fetchAsync(this.url("/api/v1/request/count"));
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
return await getStatsSchema.parseAsync(await response.json()); return await getStatsSchema.parseAsync(await response.json());
} }
public async getUsersAsync(): Promise<RequestUser[]> { public async getUsersAsync(): Promise<RequestUser[]> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), { const client = await this.createFetchClientAsync();
headers: { const response = await client.fetchAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }));
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const users = (await getUsersSchema.parseAsync(await response.json())).results; const users = (await getUsersSchema.parseAsync(await response.json())).results;
return users.map((user): RequestUser => { return users.map((user): RequestUser => {
return { return {
@@ -238,75 +287,58 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> { public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name }); logger.info("Approving media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { const client = await this.createFetchClientAsync();
method: "POST", await client
headers: { .fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/approve`), {
"X-Api-Key": this.getSecretValue("apiKey"), method: "POST",
}, })
}).then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
logger.error( logger.error(
new ErrorWithMetadata("Failed to approve media request", { new ErrorWithMetadata("Failed to approve media request", {
requestId, requestId,
integration: this.integration.name, integration: this.integration.name,
reason: `${response.status} ${response.statusText}`, reason: `${response.status} ${response.statusText}`,
url: response.url, url: response.url,
}), }),
); );
} }
logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
}); });
} }
public async declineRequestAsync(requestId: number): Promise<void> { public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name }); logger.info("Declining media request", { requestId, integration: this.integration.name });
const client = await this.createFetchClientAsync();
await client
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
})
.then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
method: "POST", });
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
} }
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> { private async getItemInformationAsync(
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), { client: OverseerrFetchClient,
headers: { type: MediaRequest["type"],
"X-Api-Key": this.getSecretValue("apiKey"), id: number,
}, ): Promise<MediaInformation> {
}); const response = await client.fetchAsync(this.url(`/api/v1/${type}/${id}`));
const rawData = (await response.json()) as TvInformation | MovieInformation;
if (type === "tv") { return mediaInformationFromResponse[type](rawData as never);
const series = (await response.json()) as TvInformation;
return {
name: series.name,
backdropPath: series.backdropPath ?? series.posterPath,
posterPath: series.posterPath ?? series.backdropPath,
airDate: series.firstAirDate,
} satisfies MediaInformation;
}
const movie = (await response.json()) as MovieInformation;
return {
name: movie.title,
backdropPath: movie.backdropPath ?? movie.posterPath,
posterPath: movie.posterPath ?? movie.backdropPath,
airDate: movie.releaseDate,
} satisfies MediaInformation;
} }
private constructAvatarUrl(avatar: string) { private constructAvatarUrl(avatar: string) {
@@ -341,6 +373,37 @@ interface MovieInformation {
releaseDate: string; releaseDate: string;
} }
const mediaInformationFromResponse = {
tv: (series: TvInformation): MediaInformation => ({
name: series.name,
backdropPath: series.backdropPath ?? series.posterPath,
posterPath: series.posterPath ?? series.backdropPath,
airDate: series.firstAirDate,
}),
movie: (movie: MovieInformation): MediaInformation => ({
name: movie.title,
backdropPath: movie.backdropPath ?? movie.posterPath,
posterPath: movie.posterPath ?? movie.backdropPath,
airDate: movie.releaseDate,
}),
} satisfies Record<MediaRequest["type"], (data: TvInformation | MovieInformation) => MediaInformation>;
const mediaInfoRequestsSchema = z
.object({
requests: z
.array(
z.object({
seasons: z.array(
z.object({
seasonNumber: z.number(),
}),
),
}),
)
.optional(),
})
.optional();
const mediaInformationSchema = z.union([ const mediaInformationSchema = z.union([
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -355,14 +418,22 @@ const mediaInformationSchema = z.union([
), ),
numberOfSeasons: z.number(), numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
overview: z.string(), overview: z.string(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
]); ]);
const searchMediaInfoSchema = z
.object({
status: z.number(),
})
.optional();
const searchSchema = z.object({ const searchSchema = z.object({
results: z results: z
.array( .array(
@@ -373,7 +444,7 @@ const searchSchema = z.object({
name: z.string(), name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: z.object({}).optional(), mediaInfo: searchMediaInfoSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -381,14 +452,14 @@ const searchSchema = z.object({
title: z.string(), title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: z.object({}).optional(), mediaInfo: searchMediaInfoSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
mediaType: z.literal("person"), mediaType: z.literal("person"),
name: z.string(), name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(), profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: z.object({}).optional(), mediaInfo: searchMediaInfoSchema,
}), }),
]), ]),
) )
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table"; import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
} }
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => { export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({ const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({
integrationId: innerProps.integrationId, integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId, mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType, mediaType: innerProps.mediaType,
}); });
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({ const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
onSuccess() { onSuccess() {
actions.closeModal(); actions.closeModal();
showSuccessNotification({ showSuccessNotification({
@@ -33,6 +33,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
const isPending = isPendingQuery || isPendingMutation; const isPending = isPendingQuery || isPendingMutation;
const t = useI18n(); const t = useI18n();
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
const columns = useMemo<MRT_ColumnDef<Season>[]>( const columns = useMemo<MRT_ColumnDef<Season>[]>(
() => [ () => [
@@ -44,8 +45,18 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
accessorKey: "episodeCount", accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"), header: t("search.engine.media.request.modal.table.header.episodes"),
}, },
{
id: "status",
header: t("search.engine.media.request.modal.table.header.status"),
Cell: ({ row }) =>
requestedSeasons.has(row.original.seasonNumber) ? (
<Badge size="xs" color="violet" variant="light">
{t("search.engine.media.request.modal.table.status.requested")}
</Badge>
) : null,
},
], ],
[], [data?.requestedSeasons],
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -56,7 +67,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
enablePagination: false, enablePagination: false,
enableSorting: false, enableSorting: false,
enableSelectAll: true, enableSelectAll: true,
enableRowSelection: true, enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
mantineTableProps: { mantineTableProps: {
highlightOnHover: false, highlightOnHover: false,
striped: "odd", striped: "odd",
@@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
setQuery(interaction.query); setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") { } else if (interaction.type === "mode") {
if (interaction.query !== undefined) {
setQuery(interaction.query);
}
setMode(interaction.mode); setMode(interaction.mode);
} else if (interaction.type === "children") { } else if (interaction.type === "children") {
setChildrenOptions(interaction); setChildrenOptions(interaction);
@@ -1,20 +1,28 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react"; import { IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { OpenMediaRequestSearchOptions } from "../index";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups"; import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
import { selectAction, spotlightStore } from "../spotlight-store"; import {
mediaRequestSearchEvent,
mediaRequestSearchScopeAtom,
selectAction,
spotlightActions,
spotlightStore,
} from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -49,10 +57,35 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null); const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n(); const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
// Works as always the same amount of hooks are executed // Works as always the same amount of hooks are executed
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups(); const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroups(); const homeEmptyGroups = useHomeEmptyGroups();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
useEffect(() => {
const handleMediaRequestSearch = (event: Event) => {
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
setMediaRequestSearchScope({ integrationIds });
setMode("media");
setChildrenOptions(null);
setQuery(query ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery]);
useEffect(() => {
if (mode !== "media") {
setMediaRequestSearchScope({});
}
}, [mode, setMediaRequestSearchScope]);
return ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
@@ -87,13 +120,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.Search <MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`} placeholder={`${t("search.placeholder")}...`}
ref={inputRef} ref={inputRef}
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48} leftSectionWidth={hasModeCharacter ? 80 : 48}
leftSection={ leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%"> <Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%"> <Center w={48} h="100%">
<IconSearch stroke={1.5} /> <IconSearch stroke={1.5} />
</Center> </Center>
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null} {hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group> </Group>
} }
styles={{ styles={{
+17 -2
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { spotlightActions } from "./spotlight-store"; import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight"; export { Spotlight } from "./components/spotlight";
export { openSpotlight }; export { openSpotlight, openMediaRequestSearch };
export { export {
SpotlightProvider, SpotlightProvider,
useRegisterSpotlightContextResults, useRegisterSpotlightContextResults,
@@ -11,3 +11,18 @@ export {
} from "./modes/home/context"; } from "./modes/home/context";
const openSpotlight = spotlightActions.open; const openSpotlight = spotlightActions.open;
export interface OpenMediaRequestSearchOptions {
integrationIds?: string[];
query?: string;
}
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
}
spotlightActions.open();
};
export { mediaRequestSearchEvent };
+1 -1
View File
@@ -12,7 +12,7 @@ const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(), createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(), createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(), createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
createSearchInteraction("children").optionsType<{ createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"]; useActions: CreateChildrenOptionsProps<any>["useActions"];
@@ -64,6 +64,36 @@ type MediaRequestChildrenProps = {
kind: IntegrationKind; kind: IntegrationKind;
url: string; url: string;
id: string; id: string;
permissions?: {
hasInteractAccess: boolean;
};
};
};
export const useMediaRequestSearchInteraction = (
integration: MediaRequestChildrenProps["integration"],
searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "children"> => {
const { openSearchInNewTab } = useSettings();
const type = searchResult.type;
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: openSearchInNewTab,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration,
}),
}; };
}; };
@@ -71,8 +101,6 @@ export const useFromIntegrationSearchInteraction = (
searchEngine: SearchEngine, searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult, searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type !== "fromIntegration") { if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type"); throw new Error("Invalid search engine type");
} }
@@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = (
) && ) &&
"type" in searchResult "type" in searchResult
) { ) {
const type = searchResult.type; return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: openSearchInNewTab,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration: searchEngine.integration,
}),
};
} }
return { return {
@@ -115,13 +125,15 @@ export const useFromIntegrationSearchInteraction = (
}; };
}; };
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() { useActions() {
const { openModal } = useModalAction(RequestMediaModal); const { openModal } = useModalAction(RequestMediaModal);
return [ return [
{ {
key: "request", key: "request",
hide: (option) => option.result.inLibrary, hide: (option) =>
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) { Component(option) {
const t = useScopedI18n("search.mode.media"); const t = useScopedI18n("search.mode.media");
return ( return (
@@ -19,6 +19,7 @@ import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board"; import { appIntegrationBoardMode } from "../app-integration-board";
import { commandMode } from "../command"; import { commandMode } from "../command";
import { externalMode } from "../external"; import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page"; import { pageMode } from "../page";
import { userGroupMode } from "../user-group"; import { userGroupMode } from "../user-group";
@@ -33,7 +34,7 @@ type QuickLinkOption = {
export const useHomeEmptyGroups = () => { export const useHomeEmptyGroups = () => {
const { data: session } = useSession(); const { data: session } = useSession();
const tPages = useScopedI18n("search.mode.page.group.page.option"); const tPages = useScopedI18n("search.mode.page.group.page.option");
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode]; const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
if (session?.user.permissions.includes("admin")) { if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode); visibleSearchModes.unshift(userGroupMode);
@@ -87,7 +88,7 @@ export const useHomeEmptyGroups = () => {
useInteraction: interaction.link(({ path }) => ({ href: path })), useInteraction: interaction.link(({ path }) => ({ href: path })),
}), }),
createGroup({ createGroup({
keyPath: "character", keyPath: "modeKey",
title: (t) => t("search.mode.help.group.mode.title"), title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => { Component: ({ modeKey, character }) => {
@@ -96,7 +97,7 @@ export const useHomeEmptyGroups = () => {
return ( return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between"> <Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text> <Text>{t("help")}</Text>
<Kbd size="sm">{character}</Kbd> {character && <Kbd size="sm">{character}</Kbd>}
</Group> </Group>
); );
}, },
@@ -20,6 +20,7 @@ type GroupItem = {
name: string; name: string;
description?: string; description?: string;
icon: TablerIcon | string; icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
}; };
@@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
); );
return ( return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs"> <Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
{icon} {icon}
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
@@ -91,9 +92,7 @@ const createDefaultSearchEntries = (
query: string, query: string,
t: TranslationFunction, t: TranslationFunction,
): GroupItem[] => { ): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) { if (!session?.user && !defaultSearchEngine) return [];
return [];
}
if (!defaultSearchEngine) { if (!defaultSearchEngine) {
return [ return [
+2
View File
@@ -2,6 +2,7 @@ import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home"; import { homeMode } from "./home";
import { mediaMode } from "./media";
import { pageMode } from "./page"; import { pageMode } from "./page";
import { userGroupMode } from "./user-group"; import { userGroupMode } from "./user-group";
@@ -11,5 +12,6 @@ export const searchModes = [
externalMode, externalMode,
commandMode, commandMode,
pageMode, pageMode,
mediaMode,
homeMode, homeMode,
] as const; ] as const;
@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { mediaRequestSearchGroup } from "./media-request-search-group";
export const mediaMode = {
modeKey: "media",
character: undefined,
groups: [mediaRequestSearchGroup],
} satisfies SearchMode;
@@ -0,0 +1,172 @@
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconMovie, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { MediaAvailability } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
const availabilityLabels: Partial<Record<MediaAvailability, string>> = {
available: "Available",
partiallyAvailable: "Partial",
processing: "Processing",
requested: "Requested",
pending: "Requested",
};
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
type MediaRequestSearchOption =
| {
key: string;
kind: "disabled" | "hint";
label: string;
description: string;
}
| {
key: string;
kind: "result";
result: MediaRequestSearchResult;
};
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
keyPath: "key",
title: (t) => t("search.mode.media.group.title"),
Component(option) {
if (option.kind !== "result") {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
{option.kind === "disabled" ? <IconMovie stroke={1.5} /> : <IconSearch stroke={1.5} />}
<Stack gap={0}>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="gray.6">
{option.description}
</Text>
</Stack>
</Group>
);
}
const { result } = option;
const badgeLabel = result.availability ? availabilityLabels[result.availability as MediaAvailability] : undefined;
const badgeColor = result.availability
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
: undefined;
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{result.image ? (
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
) : (
<IconSearch stroke={1.5} size={35} />
)}
<Stack gap={2} style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Text>{result.name}</Text>
{badgeLabel && (
<Badge size="xs" color={badgeColor} variant="light">
{badgeLabel}
</Badge>
)}
</Group>
<Text c="dimmed" size="sm" lineClamp={2}>
{result.text ?? getIntegrationName(result.integration.kind)}
</Text>
</Stack>
</Group>
);
},
useInteraction(option) {
if (option.kind !== "result") {
return {
type: "none",
};
}
return useMediaRequestSearchInteraction(option.result.integration, option.result);
},
useQueryOptions(query) {
const tMedia = useScopedI18n("search.mode.media");
const scope = useAtomValue(mediaRequestSearchScopeAtom);
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
);
const hasScopedTargets = scopedTargets.length > 0;
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
{ query: debouncedQuery, integrationIds },
{
enabled: shouldSearch,
placeholderData: keepPreviousData,
select: (data) => data.slice(0, 10),
},
);
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
if (!targetsQuery.data) {
return {
isLoading,
isError,
data: [],
};
}
if (!hasScopedTargets) {
return {
isLoading,
isError,
data: [
{
key: "disabled",
kind: "disabled",
label: tMedia("action.search.label"),
description: scope.integrationIds
? tMedia("action.search.disabled.scoped")
: tMedia("action.search.disabled.noIntegration"),
},
],
};
}
if (debouncedQuery.length === 0) {
return {
isLoading,
isError,
data: [
{
key: "hint",
kind: "hint",
label: tMedia("action.search.label"),
description: tMedia("action.search.description"),
},
],
};
}
return {
isLoading,
isError,
data: (resultsQuery.data ?? []).map((result) => ({
key: `result-${result.integration.id}-${result.type}-${result.id}`,
kind: "result",
result,
})),
};
},
});
@@ -3,9 +3,17 @@
import { clamp } from "@mantine/hooks"; import { clamp } from "@mantine/hooks";
import type { SpotlightStore } from "@mantine/spotlight"; import type { SpotlightStore } from "@mantine/spotlight";
import { createSpotlight } from "@mantine/spotlight"; import { createSpotlight } from "@mantine/spotlight";
import { atom } from "jotai";
export const [spotlightStore, spotlightActions] = createSpotlight(); export const [spotlightStore, spotlightActions] = createSpotlight();
export interface MediaRequestSearchScope {
integrationIds?: string[];
}
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export const setSelectedAction = (index: number, store: SpotlightStore) => { export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index })); store.updateState((state) => ({ ...state, selected: index }));
}; };
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Contrasenya", "label": "Contrasenya",
"suggestion": { "requirement": {
"length": "Inclou com a mínim 8 caràcters", "length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules", "lowercase": "Inclou minúscules",
"uppercase": "Inclou majúscules", "uppercase": "Inclou majúscules",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "密码", "label": "密码",
"suggestion": { "requirement": {
"length": "至少包含 8 个字符", "length": "至少包含 8 个字符",
"lowercase": "包含小写字母", "lowercase": "包含小写字母",
"uppercase": "包含大写字母", "uppercase": "包含大写字母",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "crwdns26:0crwdne26:0", "label": "crwdns26:0crwdne26:0",
"suggestion": { "requirement": {
"length": "crwdns28:0crwdne28:0", "length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns30:0crwdne30:0", "lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns32:0crwdne32:0", "uppercase": "crwdns32:0crwdne32:0",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Heslo", "label": "Heslo",
"suggestion": { "requirement": {
"length": "Obsahuje alespoň 8 znaků", "length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno", "lowercase": "Obsahuje malé písmeno",
"uppercase": "Obsahuje velké písmeno", "uppercase": "Obsahuje velké písmeno",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Adgangskode", "label": "Adgangskode",
"suggestion": { "requirement": {
"length": "Inkluderer mindst 8 tegn", "length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver", "lowercase": "Inkluderer små bogstaver",
"uppercase": "Inkluderer store bogstaver", "uppercase": "Inkluderer store bogstaver",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"suggestion": { "requirement": {
"length": "Enthält mindestens 8 Zeichen", "length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben", "lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben", "uppercase": "Enthält Großbuchstaben",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"suggestion": { "requirement": {
"length": "Enthält mindestens 8 Zeichen", "length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben", "lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben", "uppercase": "Enthält Großbuchstaben",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Κωδικός", "label": "Κωδικός",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα", "lowercase": "Περιλαμβάνει πεζό γράμμα",
"uppercase": "Περιλαμβάνει κεφαλαίο γράμμα", "uppercase": "Περιλαμβάνει κεφαλαίο γράμμα",
+21 -3
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestion": { "requirement": {
"length": "Includes a minimum of 8 characters", "length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter", "lowercase": "Includes a lower-case letter",
"uppercase": "Includes an upper-case letter", "uppercase": "Includes an upper-case letter",
@@ -4551,9 +4551,23 @@
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "", "requestMovie": "",
"requestSeries": "", "requestSeries": "",
"openIn": "" "openIn": "",
"group": {
"title": "Movies and shows"
},
"action": {
"search": {
"label": "Search for movies or shows",
"description": "Start typing to search your media request integrations",
"disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"scoped": "This widget does not have an accessible media request integration."
}
}
}
}, },
"external": { "external": {
"help": "", "help": "",
@@ -4829,7 +4843,11 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes" "episodes": "Episodes",
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+22 -8
View File
@@ -132,9 +132,8 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestionsTitle": "Password suggestions", "requirement": {
"suggestion": { "length": "Includes at least 8 characters",
"length": "At least 8 characters",
"lowercase": "Includes lowercase letter", "lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter", "uppercase": "Includes uppercase letter",
"number": "Includes number", "number": "Includes number",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Open in new tab" "label": "Open in new tab"
}, },
"withBorder": {
"label": "Show border"
},
"items": { "items": {
"label": "Bookmarks", "label": "Bookmarks",
"add": "Add bookmark" "add": "Add bookmark"
@@ -4555,9 +4551,23 @@
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "Request movie", "requestMovie": "Request movie",
"requestSeries": "Request series", "requestSeries": "Request series",
"openIn": "Open in {kind}" "openIn": "Open in {kind}",
"group": {
"title": "Movies and shows"
},
"action": {
"search": {
"label": "Search for movies or shows",
"description": "Start typing to search your media request integrations",
"disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"scoped": "This widget does not have an accessible media request integration."
}
}
}
}, },
"external": { "external": {
"help": "Use an external search engine", "help": "Use an external search engine",
@@ -4833,7 +4843,11 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes" "episodes": "Episodes",
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Contraseña", "label": "Contraseña",
"suggestion": { "requirement": {
"length": "Incluye al menos 8 caracteres", "length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula", "lowercase": "Incluye al menos una letra minúscula",
"uppercase": "Incluye al menos una letra mayúscula", "uppercase": "Incluye al menos una letra mayúscula",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "", "label": "",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
"uppercase": "", "uppercase": "",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Mot de passe", "label": "Mot de passe",
"suggestion": { "requirement": {
"length": "Contient au moins 8 caractères", "length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule", "lowercase": "Inclut une lettre minuscule",
"uppercase": "Inclut une lettre majuscule", "uppercase": "Inclut une lettre majuscule",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "סיסמה", "label": "סיסמה",
"suggestion": { "requirement": {
"length": "כולל לפחות 8 תווים", "length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות", "lowercase": "אפשר אותיות קטנות",
"uppercase": "אפשר אותיות גדולות", "uppercase": "אפשר אותיות גדולות",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Lozinka", "label": "Lozinka",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "Uključuje mala slova", "lowercase": "Uključuje mala slova",
"uppercase": "Uključuje veliko slovo", "uppercase": "Uključuje veliko slovo",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Jelszó", "label": "Jelszó",
"suggestion": { "requirement": {
"length": "Tartalmazzon legalább 8 karaktert", "length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt", "lowercase": "Tartalmazzon kisbetűt",
"uppercase": "Tartalmazzon nagybetűt", "uppercase": "Tartalmazzon nagybetűt",
+6 -6
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestion": { "requirement": {
"length": "Include almeno 8 caratteri", "length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola", "lowercase": "Include lettera minuscola",
"uppercase": "Include lettera maiuscola", "uppercase": "Include lettera maiuscola",
@@ -1056,7 +1056,7 @@
} }
}, },
"noResults": { "noResults": {
"title": "Nessun media caricato" "title": ""
} }
}, },
"common": { "common": {
@@ -1088,8 +1088,8 @@
"checkLogs": "Controlla i log per maggiori dettagli", "checkLogs": "Controlla i log per maggiori dettagli",
"tryAgain": "Riprova", "tryAgain": "Riprova",
"loading": "Caricamento", "loading": "Caricamento",
"show": "Visualizza", "show": "",
"hide": "Nascondi" "hide": ""
}, },
"here": "qui", "here": "qui",
"iconPicker": { "iconPicker": {
@@ -1255,7 +1255,7 @@
"label": { "label": {
"create": "Nuova categoria", "create": "Nuova categoria",
"changePosition": "Cambia posizione", "changePosition": "Cambia posizione",
"settings": "Impostazioni" "settings": ""
} }
}, },
"openAllInNewTabs": { "openAllInNewTabs": {
@@ -3459,7 +3459,7 @@
"private": "Questa board è privata" "private": "Questa board è privata"
}, },
"noResults": { "noResults": {
"title": "Ancora nessuna board" "title": ""
}, },
"modal": { "modal": {
"createBoard": { "createBoard": {
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "パスワード", "label": "パスワード",
"suggestion": { "requirement": {
"length": "少なくとも 8 文字以上を含む", "length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む", "lowercase": "小文字を含む",
"uppercase": "大文字を含む", "uppercase": "大文字を含む",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "비밀번호", "label": "비밀번호",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "소문자 포함", "lowercase": "소문자 포함",
"uppercase": "대문자 포함", "uppercase": "대문자 포함",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Slaptažodis", "label": "Slaptažodis",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
"uppercase": "", "uppercase": "",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Parole", "label": "Parole",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "Ietver mazo burtu", "lowercase": "Ietver mazo burtu",
"uppercase": "Ietver lielo burtu", "uppercase": "Ietver lielo burtu",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Wachtwoord", "label": "Wachtwoord",
"suggestion": { "requirement": {
"length": "Bevat ten minste 8 tekens", "length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter", "lowercase": "Inclusief kleine letter",
"uppercase": "Inclusief hoofdletter", "uppercase": "Inclusief hoofdletter",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Passord", "label": "Passord",
"suggestion": { "requirement": {
"length": "Inkluderer minst 8 tegn", "length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav", "lowercase": "Inkluderer liten bokstav",
"uppercase": "Inkluderer stor bokstav", "uppercase": "Inkluderer stor bokstav",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Hasło", "label": "Hasło",
"suggestion": { "requirement": {
"length": "Zawiera co najmniej 8 znaków", "length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę", "lowercase": "Zawiera małą literę",
"uppercase": "Zawiera wielką literę", "uppercase": "Zawiera wielką literę",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Senha", "label": "Senha",
"suggestion": { "requirement": {
"length": "Inclua pelo menos 8 caracteres", "length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas", "lowercase": "Inclui letras minúsculas",
"uppercase": "Inclui letras maiúsculas", "uppercase": "Inclui letras maiúsculas",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "", "label": "",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
"uppercase": "", "uppercase": "",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Parola", "label": "Parola",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "Să includă litere mici", "lowercase": "Să includă litere mici",
"uppercase": "Să includă litere mari", "uppercase": "Să includă litere mari",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"suggestion": { "requirement": {
"length": "Содержит не менее 8 символов", "length": "Содержит не менее 8 символов",
"lowercase": "Содержит строчную букву", "lowercase": "Содержит строчную букву",
"uppercase": "Содержит заглавную букву", "uppercase": "Содержит заглавную букву",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Heslo", "label": "Heslo",
"suggestion": { "requirement": {
"length": "Obsahuje aspoň 8 znakov", "length": "Obsahuje aspoň 8 znakov",
"lowercase": "Zahŕňa malé písmeno", "lowercase": "Zahŕňa malé písmeno",
"uppercase": "Zahŕňa veľké písmená", "uppercase": "Zahŕňa veľké písmená",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Geslo", "label": "Geslo",
"suggestion": { "requirement": {
"length": "", "length": "",
"lowercase": "Vključuje male črke", "lowercase": "Vključuje male črke",
"uppercase": "Vključuje velike tiskane črke", "uppercase": "Vključuje velike tiskane črke",
+107 -107
View File
@@ -82,7 +82,7 @@
"form": { "form": {
"name": { "name": {
"label": "Gruppens namn", "label": "Gruppens namn",
"description": "Namnet måste matcha admin-grupp i extern tjänst" "description": ""
} }
} }
}, },
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Lösenord", "label": "Lösenord",
"suggestion": { "requirement": {
"length": "Använd minst 8 tecken", "length": "Använd minst 8 tecken",
"lowercase": "Använd liten bokstav", "lowercase": "Använd liten bokstav",
"uppercase": "Använd stor bokstav", "uppercase": "Använd stor bokstav",
@@ -159,7 +159,7 @@
"label": "Visa sökresultat i ny flik" "label": "Visa sökresultat i ny flik"
}, },
"ddgBangs": { "ddgBangs": {
"label": "Aktivera DuckDuckGo bangs" "label": ""
} }
}, },
"error": { "error": {
@@ -252,10 +252,10 @@
"changeDdgBangs": { "changeDdgBangs": {
"notification": { "notification": {
"success": { "success": {
"message": "DuckDuckGo bangs inställningen ändrad" "message": ""
}, },
"error": { "error": {
"message": "DuckDuckGo bangs inställningen kunde inte ändras" "message": ""
} }
} }
}, },
@@ -350,12 +350,12 @@
"description": "Tillåt medlemmar att addera valfri applikation på sina tavlor" "description": "Tillåt medlemmar att addera valfri applikation på sina tavlor"
}, },
"modify-all": { "modify-all": {
"label": "Modifiera alla appar", "label": "",
"description": "Tillåt att medlemmar modifierar alla appar" "description": ""
}, },
"full-all": { "full-all": {
"label": "Fullständiga rättigheter till appar", "label": "",
"description": "Tillåt att medlemmar hanterar, använder och raderar alla appar" "description": ""
} }
} }
}, },
@@ -363,20 +363,20 @@
"title": "Tavlor", "title": "Tavlor",
"item": { "item": {
"create": { "create": {
"label": "Skapa tavlor", "label": "",
"description": "Tillåt att medlemmar skapar tavlor" "description": ""
}, },
"view-all": { "view-all": {
"label": "Se alla tavlor", "label": "",
"description": "Tillåt att medlemmar ser alla tavlor" "description": ""
}, },
"modify-all": { "modify-all": {
"label": "Modifiera alla tavlor", "label": "",
"description": "Tillåt att medlemmar modifierar alla tavlor (Detta inkluderar inte åtkomstkontroll eller farozonen)" "description": ""
}, },
"full-all": { "full-all": {
"label": "Fullständiga rättigheter till tavlor", "label": "",
"description": "Tillåt att medlemmar ser, modifierar och tar bort alla tavlor (detta inkluderar åtkomstkontroll och farozonen)" "description": ""
} }
} }
}, },
@@ -392,38 +392,38 @@
"description": "Tillåter medlemmar att addera valfri integration till sina tavlor" "description": "Tillåter medlemmar att addera valfri integration till sina tavlor"
}, },
"interact-all": { "interact-all": {
"label": "Interagera med alla integrationer", "label": "",
"description": "Tillåt att medlemmar interagerar med alla integrationer" "description": ""
}, },
"full-all": { "full-all": {
"label": "Fullständiga rättigheter till integrationer", "label": "",
"description": "Tillåt att medlemmar hanterar, använder och interagerar med alla integrationer" "description": ""
} }
} }
}, },
"media": { "media": {
"title": "Media", "title": "",
"item": { "item": {
"upload": { "upload": {
"label": "Ladda upp media", "label": "",
"description": "Tillåt att medlemmar laddar upp media" "description": ""
}, },
"view-all": { "view-all": {
"label": "Se all media", "label": "",
"description": "Tillåt att medlemmar ser all media" "description": ""
}, },
"full-all": { "full-all": {
"label": "Fullständiga rättigheter till media", "label": "",
"description": "Tillåt att medlemmar hanterar och raderar all media" "description": ""
} }
} }
}, },
"other": { "other": {
"title": "Annat", "title": "",
"item": { "item": {
"view-logs": { "view-logs": {
"label": "Se loggar", "label": "",
"description": "Tillåt att medlemmar ser loggar" "description": ""
} }
} }
}, },
@@ -435,32 +435,32 @@
"description": "Tillåt medlemmar att skapa sökmotorer" "description": "Tillåt medlemmar att skapa sökmotorer"
}, },
"modify-all": { "modify-all": {
"label": "Modifiera alla sökmotorer", "label": "",
"description": "Tillåt att medlemmar modifierar alla sökmotorer" "description": ""
}, },
"full-all": { "full-all": {
"label": "Fullständiga rättigheter till sökmotorer", "label": "",
"description": "Tillåt att medlemmar hanterar och raderar alla sökmotorer" "description": ""
} }
} }
} }
}, },
"memberNotice": { "memberNotice": {
"mixed": "Vissa medlemmar är från externa tjänster och kan inte hantera här", "mixed": "",
"external": "Alla medlemmar är från externa tjänster och kan inte hanteras här" "external": ""
}, },
"reservedNotice": { "reservedNotice": {
"message": "Denna grupp är reserverad för systemet så vissa åtgärder är begränsade. <checkoutDocs></checkoutDocs>" "message": ""
}, },
"action": { "action": {
"create": { "create": {
"label": "Addera en grupp", "label": "Addera en grupp",
"notification": { "notification": {
"success": { "success": {
"message": "Gruppen har skapats" "message": ""
}, },
"error": { "error": {
"message": "Gruppen kunde inte skapas" "message": ""
} }
} }
}, },
@@ -470,7 +470,7 @@
"confirm": "Är du säker på att du vill överföra ägarskapet för gruppen {name} till {username}?", "confirm": "Är du säker på att du vill överföra ägarskapet för gruppen {name} till {username}?",
"notification": { "notification": {
"success": { "success": {
"message": "Överfört grupp {group} till {user}" "message": ""
}, },
"error": { "error": {
"message": "Kunde inte överföra ägarskap" "message": "Kunde inte överföra ägarskap"
@@ -501,38 +501,38 @@
"notification": { "notification": {
"success": { "success": {
"title": "Behörigheter sparade", "title": "Behörigheter sparade",
"message": "Rättigheterna sparade" "message": ""
}, },
"error": { "error": {
"title": "Rättigheterna ej sparade", "title": "",
"message": "Rättigheterna har inte sparats" "message": ""
} }
} }
}, },
"update": { "update": {
"notification": { "notification": {
"success": { "success": {
"message": "Gruppen {name} har sparats" "message": ""
}, },
"error": { "error": {
"message": "Kunde inte spara gruppen {name}" "message": ""
} }
} }
}, },
"select": { "select": {
"label": "Välj en grupp", "label": "Välj en grupp",
"notFound": "Ingen grupp hittad" "notFound": ""
}, },
"settings": { "settings": {
"board": { "board": {
"notification": { "notification": {
"success": { "success": {
"title": "Inställningarna sparades", "title": "Inställningarna sparades",
"message": "Inställningarna för tavlan har sparats" "message": ""
}, },
"error": { "error": {
"title": "Kunde inte spara inställningarna", "title": "Kunde inte spara inställningarna",
"message": "Kunde inte spara inställningarna för tavlan" "message": ""
} }
} }
} }
@@ -540,10 +540,10 @@
"changePosition": { "changePosition": {
"notification": { "notification": {
"success": { "success": {
"message": "Ordning ändrad" "message": ""
}, },
"error": { "error": {
"message": "Ordningen kunde ändras" "message": ""
} }
} }
} }
@@ -559,20 +559,20 @@
"list": { "list": {
"title": "Applikationer", "title": "Applikationer",
"noResults": { "noResults": {
"title": "Det finns inga appar ännu", "title": "",
"action": "Skapa din första app" "action": ""
} }
}, },
"create": { "create": {
"title": "Addera applikation", "title": "Addera applikation",
"notification": { "notification": {
"success": { "success": {
"title": "Skapandet lyckades", "title": "",
"message": "Appen skapades framgångsrikt" "message": ""
}, },
"error": { "error": {
"title": "Skapandet misslyckades", "title": "",
"message": "Appen kunde inte skapas" "message": ""
} }
} }
}, },
@@ -581,11 +581,11 @@
"notification": { "notification": {
"success": { "success": {
"title": "Ändringarna har sparats", "title": "Ändringarna har sparats",
"message": "Appen sparades" "message": ""
}, },
"error": { "error": {
"title": "Misslyckades med att spara ändringarna", "title": "Misslyckades med att spara ändringarna",
"message": "Appen kunde inte sparas" "message": ""
} }
} }
}, },
@@ -594,12 +594,12 @@
"message": "Är du säker på att du vill ta bort applikationen {name}?", "message": "Är du säker på att du vill ta bort applikationen {name}?",
"notification": { "notification": {
"success": { "success": {
"title": "Borttagning lyckades", "title": "",
"message": "Appen har tagits bort" "message": ""
}, },
"error": { "error": {
"title": "Borttagning misslyckades", "title": "",
"message": "Kunde inte ta bort appen" "message": ""
} }
} }
} }
@@ -612,21 +612,21 @@
"label": "Beskrivning" "label": "Beskrivning"
}, },
"url": { "url": {
"label": "URL" "label": ""
}, },
"useDifferentUrlForPing": { "useDifferentUrlForPing": {
"checkbox": { "checkbox": {
"label": "Använd annan URL för ping", "label": "",
"description": "Användbart om Homarr kan komma åt appen direkt via ett internt host-namn eller nätverk för att undvika bandbreddsanvändning hos internetleverantör" "description": ""
} }
} }
}, },
"action": { "action": {
"select": { "select": {
"label": "Välj app", "label": "",
"notFound": "Ingen app hittades", "notFound": "",
"search": "Sök efter en applikation", "search": "Sök efter en applikation",
"noResults": "Inga resultat", "noResults": "",
"action": "Välj {app}", "action": "Välj {app}",
"title": "Välj en applikation att lägga till på tavlan" "title": "Välj en applikation att lägga till på tavlan"
}, },
@@ -642,56 +642,56 @@
"page": { "page": {
"list": { "list": {
"title": "Integrationer", "title": "Integrationer",
"search": "Sök integrationer", "search": "",
"noResults": { "noResults": {
"title": "Det finns inga integrationer ännu" "title": ""
} }
}, },
"create": { "create": {
"title": "Ny {name} integration", "title": "",
"notification": { "notification": {
"success": { "success": {
"title": "Skapandet lyckades", "title": "",
"message": "Integrationen har skapats" "message": ""
}, },
"error": { "error": {
"title": "Skapandet misslyckades", "title": "",
"message": "Integrationen kunde inte skapas" "message": ""
} }
}, },
"app": { "app": {
"option": { "option": {
"existing": { "existing": {
"title": "Befintlig", "title": "",
"label": "Välj befintlig app" "label": ""
}, },
"new": { "new": {
"title": "Ny", "title": "",
"url": { "url": {
"label": "App URL", "label": "",
"description": "URL:en som appen kommer att öppna när den används från tavlan" "description": ""
} }
} }
} }
} }
}, },
"edit": { "edit": {
"title": "Redigera {name} integration", "title": "",
"notification": { "notification": {
"success": { "success": {
"title": "Ändringarna har sparats", "title": "Ändringarna har sparats",
"message": "Integrationen sparad" "message": ""
}, },
"error": { "error": {
"title": "Kunde inte tillämpa ändringar", "title": "",
"message": "Integrationen kunde inte sparas" "message": ""
} }
}, },
"app": { "app": {
"action": { "action": {
"add": "Länka en app", "add": "",
"remove": "Ta bort länk", "remove": "",
"select": "Välj en app att länka till" "select": ""
} }
} }
}, },
@@ -700,12 +700,12 @@
"message": "Är du säker på att du vill ta bort integrationen {name}?", "message": "Är du säker på att du vill ta bort integrationen {name}?",
"notification": { "notification": {
"success": { "success": {
"title": "Borttagning lyckades", "title": "",
"message": "Integrationen har tagits bort" "message": ""
}, },
"error": { "error": {
"title": "Borttagning misslyckades", "title": "",
"message": "Kunde inte ta bort integrationen" "message": ""
} }
} }
} }
@@ -715,44 +715,44 @@
"label": "Namn" "label": "Namn"
}, },
"url": { "url": {
"label": "URL" "label": ""
}, },
"attemptSearchEngineCreation": { "attemptSearchEngineCreation": {
"label": "Skapa sökmotor", "label": "",
"description": "Integrationen \"{kind}\" kan inte användas med sökmotorer. Markera detta för att automatiskt konfigurera sökmotorn." "description": ""
}, },
"app": { "app": {
"sectionTitle": "Länkad app" "sectionTitle": ""
}, },
"createApp": { "createApp": {
"label": "Skapa app", "label": "",
"description": "Skapa en app med samma namn och ikon som integrationen. Lämna fältet nedan tomt för att skapa en app med integrationens URL." "description": ""
}, },
"appHref": { "appHref": {
"placeholder": "Egen app-URL" "placeholder": ""
} }
}, },
"action": { "action": {
"create": "Ny integration" "create": ""
}, },
"testConnection": { "testConnection": {
"action": { "action": {
"create": "Testa anslutningen och skapa", "create": "",
"edit": "Testa anslutningen och skapa" "edit": ""
}, },
"error": { "error": {
"common": { "common": {
"cause": { "cause": {
"title": "Orsak med mer detaljer" "title": ""
} }
}, },
"unknown": { "unknown": {
"title": "Okänt fel", "title": "",
"description": "Ett okänt fel inträffade, öppna orsaken nedan för att se mer detaljer" "description": ""
}, },
"parse": { "parse": {
"title": "Parsningsfel", "title": "",
"description": "Svaret kunde inte analyseras. Verifiera att URL pekar på bas-URL för tjänsten." "description": ""
}, },
"authorization": { "authorization": {
"title": "", "title": "",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Şifre", "label": "Şifre",
"suggestion": { "requirement": {
"length": "En az 8 karakter içermeli", "length": "En az 8 karakter içermeli",
"lowercase": "Küçük harf içermeli", "lowercase": "Küçük harf içermeli",
"uppercase": "Büyük harf içermeli", "uppercase": "Büyük harf içermeli",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"suggestion": { "requirement": {
"length": "Включає принаймні 8 символів", "length": "Включає принаймні 8 символів",
"lowercase": "Включає малу літеру", "lowercase": "Включає малу літеру",
"uppercase": "Включає велику літеру", "uppercase": "Включає велику літеру",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "Mật khẩu", "label": "Mật khẩu",
"suggestion": { "requirement": {
"length": "Bao gồm ít nhất 8 kí tự", "length": "Bao gồm ít nhất 8 kí tự",
"lowercase": "Bao gồm chữ thường", "lowercase": "Bao gồm chữ thường",
"uppercase": "Bao gồm chữ in hoa", "uppercase": "Bao gồm chữ in hoa",
+1 -1
View File
@@ -132,7 +132,7 @@
}, },
"password": { "password": {
"label": "密碼", "label": "密碼",
"suggestion": { "requirement": {
"length": "至少包含 8 個字符", "length": "至少包含 8 個字符",
"lowercase": "包含小寫字母", "lowercase": "包含小寫字母",
"uppercase": "包含大寫字母", "uppercase": "包含大寫字母",
@@ -20,15 +20,15 @@ export const CustomPasswordInput = ({ withPasswordRequirements, ...props }: Cust
}; };
const WithPasswordRequirements = (props: PasswordInputProps) => { const WithPasswordRequirements = (props: PasswordInputProps) => {
const [password, setPassword] = useState(""); const [value, setValue] = useState("");
const onChange = (event: ChangeEvent<HTMLInputElement>) => { const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.currentTarget.value); setValue(event.currentTarget.value);
props.onChange?.(event); props.onChange?.(event);
}; };
return ( return (
<PasswordRequirementsPopover password={password}> <PasswordRequirementsPopover password={value}>
<PasswordInput {...props} onChange={onChange} /> <PasswordInput {...props} onChange={onChange} />
</PasswordRequirementsPopover> </PasswordRequirementsPopover>
); );
@@ -1,19 +1,14 @@
import { rem, Text } from "@mantine/core"; import { rem, Text } from "@mantine/core";
import { IconCheck, IconPoint } from "@tabler/icons-react"; import { IconCheck, IconX } from "@tabler/icons-react";
const getRequirementDisplay = (meets: boolean) =>
({
true: { color: "teal", Icon: IconCheck },
false: { color: "dimmed", Icon: IconPoint },
})[String(meets) as "true" | "false"];
export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) { export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
const display = getRequirementDisplay(meets);
const Icon = display.Icon;
return ( return (
<Text c={display.color} display="flex" style={{ alignItems: "center" }} size="sm"> <Text c={meets ? "teal" : "red"} display="flex" style={{ alignItems: "center" }} size="sm">
<Icon style={{ width: rem(14), height: rem(14) }} /> {meets ? (
<IconCheck style={{ width: rem(14), height: rem(14) }} />
) : (
<IconX style={{ width: rem(14), height: rem(14) }} />
)}
<Text span ml={10}> <Text span ml={10}>
{label} {label}
</Text> </Text>
@@ -1,24 +1,16 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react"; import { useState } from "react";
import { Popover, Progress, Text } from "@mantine/core"; import { Popover, Progress } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { passwordRequirements } from "@homarr/validation/user"; import { passwordRequirements } from "@homarr/validation/user";
import { PasswordRequirement } from "./password-requirement"; import { PasswordRequirement } from "./password-requirement";
const strengthColorMap = [
{ min: 100, color: "teal" },
{ min: 51, color: "yellow" },
{ min: 0, color: "dimmed" },
] as const;
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => { export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
const tPassword = useScopedI18n("user.field.password");
const requirements = useRequirements(); const requirements = useRequirements();
const strength = useStrength(password); const strength = useStrength(password);
const [popoverOpened, setPopoverOpened] = useState(false); const [popoverOpened, setPopoverOpened] = useState(false);
const strengthColor = strengthColorMap.find(({ min }) => strength >= min)?.color ?? "dimmed";
const checks = ( const checks = (
<> <>
{requirements.map((requirement) => ( {requirements.map((requirement) => (
@@ -27,6 +19,8 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
</> </>
); );
const color = strength === 100 ? "teal" : strength > 50 ? "yellow" : "red";
return ( return (
<Popover opened={popoverOpened} position="bottom" width="target" transitionProps={{ transition: "pop" }}> <Popover opened={popoverOpened} position="bottom" width="target" transitionProps={{ transition: "pop" }}>
<Popover.Target> <Popover.Target>
@@ -35,10 +29,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
</div> </div>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<Progress color={strengthColor} value={strength} size={5} mb="xs" /> <Progress color={color} value={strength} size={5} mb="xs" />
<Text size="sm" c="dimmed" mb="xs">
{tPassword("suggestionsTitle")}
</Text>
{checks} {checks}
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -46,7 +37,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
}; };
const useRequirements = () => { const useRequirements = () => {
const t = useScopedI18n("user.field.password.suggestion"); const t = useScopedI18n("user.field.password.requirement");
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) })); return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
}; };
+16 -2
View File
@@ -19,10 +19,24 @@ export const passwordRequirements = [
{ check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!\-~`"_/\\[\]{}]/), value: "special" }, { check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!\-~`"_/\\[\]{}]/), value: "special" },
] satisfies { ] satisfies {
check: (value: string) => boolean; check: (value: string) => boolean;
value: keyof TranslationObject["user"]["field"]["password"]["suggestion"]; value: keyof TranslationObject["user"]["field"]["password"]["requirement"];
}[]; }[];
export const userPasswordSchema = z.string().min(8).max(255); export const userPasswordSchema = z
.string()
.min(8)
.max(255)
.refine(
(value) => {
return passwordRequirements.every((requirement) => requirement.check(value));
},
{
params: createCustomErrorParams({
key: "passwordRequirements",
params: {},
}),
},
);
const addConfirmPasswordRefinement = < const addConfirmPasswordRefinement = <
TSchema extends z.ZodObject<{ password: z.core.$ZodString; confirmPassword: z.core.$ZodString }, z.core.$strip>, TSchema extends z.ZodObject<{ password: z.core.$ZodString; confirmPassword: z.core.$ZodString }, z.core.$strip>,
+2 -17
View File
@@ -16,7 +16,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
const board = useRequiredBoard(); const board = useRequiredBoard();
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, { const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
select(data) { select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
}, },
}); });
@@ -56,7 +56,6 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hideIcon={options.hideIcon} hideIcon={options.hideIcon}
hideHostname={options.hideHostname} hideHostname={options.hideHostname}
openNewTab={options.openNewTab} openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null} hasIconColor={board.iconColor !== null}
/> />
)} )}
@@ -68,7 +67,6 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hideIcon={options.hideIcon} hideIcon={options.hideIcon}
hideHostname={options.hideHostname} hideHostname={options.hideHostname}
openNewTab={options.openNewTab} openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null} hasIconColor={board.iconColor !== null}
/> />
)} )}
@@ -83,7 +81,6 @@ interface FlexLayoutProps {
hideIcon: boolean; hideIcon: boolean;
hideHostname: boolean; hideHostname: boolean;
openNewTab: boolean; openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean; hasIconColor: boolean;
} }
@@ -94,7 +91,6 @@ const FlexLayout = ({
hideIcon, hideIcon,
hideHostname, hideHostname,
openNewTab, openNewTab,
withBorder,
hasIconColor, hasIconColor,
}: FlexLayoutProps) => { }: FlexLayoutProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -110,15 +106,7 @@ const FlexLayout = ({
key={app.id} key={app.id}
w="100%" w="100%"
> >
<Card <Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={4} h="100%">
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? ( {direction === "row" ? (
<VerticalItem <VerticalItem
app={app} app={app}
@@ -150,7 +138,6 @@ interface GridLayoutProps {
hideIcon: boolean; hideIcon: boolean;
hideHostname: boolean; hideHostname: boolean;
openNewTab: boolean; openNewTab: boolean;
withBorder: boolean;
itemDirection: "horizontal" | "vertical"; itemDirection: "horizontal" | "vertical";
hasIconColor: boolean; hasIconColor: boolean;
} }
@@ -161,7 +148,6 @@ const GridLayout = ({
hideIcon, hideIcon,
hideHostname, hideHostname,
openNewTab, openNewTab,
withBorder,
itemDirection, itemDirection,
hasIconColor, hasIconColor,
}: GridLayoutProps) => { }: GridLayoutProps) => {
@@ -182,7 +168,6 @@ const GridLayout = ({
h="100%" h="100%"
className={combineClasses(classes.card, classes["card-grid"])} className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius} radius={board.itemRadius}
withBorder={withBorder}
p="xs" p="xs"
> >
{itemDirection === "horizontal" ? ( {itemDirection === "horizontal" ? (
-1
View File
@@ -24,7 +24,6 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
hideIcon: factory.switch({ defaultValue: false }), hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }), hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }), openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({ items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => { ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return ( return (
@@ -1,10 +1,20 @@
.statItem { .statItem {
padding: 12px; padding: 12px;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); background-color: var(--mantine-color-gray-0);
transition: all 0.2s ease; transition: all 0.2s ease;
&:hover { &:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); background-color: var(--mantine-color-gray-1);
}
}
@media (prefers-color-scheme: dark) {
.statItem {
background-color: var(--mantine-color-dark-6);
&:hover {
background-color: var(--mantine-color-dark-5);
}
} }
} }
@@ -1,17 +1,32 @@
"use client"; "use client";
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import {
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; ActionIcon,
Anchor,
Avatar,
Badge,
Box,
Card,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api"; import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { MediaRequestStatus } from "@homarr/integrations/types"; import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
@@ -43,7 +58,7 @@ export default function MediaServerWidget({
const newData = filteredData.concat( const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
); );
return newData.sort((dataA, dataB) => { return newData.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) { if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime(); return dataB.createdAt.getTime() - dataA.createdAt.getTime();
} }
@@ -61,25 +76,46 @@ export default function MediaServerWidget({
if (mediaRequests.length === 0) throw new NoIntegrationDataError(); if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return ( return (
<ScrollArea <Box className={classes.searchRoot}>
className="mediaRequests-list-scrollArea" {!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
scrollbarSize="md" <ScrollArea
style={{ pointerEvents: isEditMode ? "none" : undefined }} className="mediaRequests-list-scrollArea"
> scrollbarSize="md"
<Stack className="mediaRequests-list-list" gap="xs" p="sm"> style={{ pointerEvents: isEditMode ? "none" : undefined }}
{mediaRequests.map((mediaRequest) => ( >
<MediaRequestCard <Stack className="mediaRequests-list-list" gap="xs" p="sm">
key={`${mediaRequest.integrationId}-${mediaRequest.id}`} {mediaRequests.map((mediaRequest) => (
request={mediaRequest} <MediaRequestCard
isTiny={width <= 256} key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
options={options} request={mediaRequest}
/> isTiny={width <= 256}
))} options={options}
</Stack> />
</ScrollArea> ))}
</Stack>
</ScrollArea>
</Box>
); );
} }
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
const t = useScopedI18n("search.mode.media");
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={classes.searchButton}
variant="light"
size="sm"
aria-label={t("action.search.label")}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
<IconSearch size={16} />
</ActionIcon>
</Tooltip>
);
};
interface MediaRequestCardProps { interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean; isTiny: boolean;
@@ -0,0 +1,24 @@
.searchRoot {
position: relative;
height: 100%;
}
.searchButton {
position: absolute;
top: var(--mantine-spacing-xs);
right: var(--mantine-spacing-xs);
z-index: 2;
opacity: 0;
transition: opacity 120ms ease;
}
.searchRoot:hover .searchButton,
.searchRoot:focus-within .searchButton {
opacity: 1;
}
@media (hover: none) {
.searchButton {
opacity: 1;
}
}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { Icon } from "@tabler/icons-react"; import type { Icon } from "@tabler/icons-react";
import { import {
IconDeviceTv, IconDeviceTv,
@@ -9,6 +9,7 @@ import {
IconMovie, IconMovie,
IconPlayerPlay, IconPlayerPlay,
IconReceipt, IconReceipt,
IconSearch,
IconThumbDown, IconThumbDown,
IconThumbUp, IconThumbUp,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -17,11 +18,13 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types"; import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
const OVERSEERR_COLOR = "#ECB000"; const OVERSEERR_COLOR = "#ECB000";
const JELLYSEERR_COLOR = "#6677CC"; const JELLYSEERR_COLOR = "#6677CC";
@@ -93,83 +96,104 @@ export default function MediaServerWidget({
const isTiny = width < 256; const isTiny = width < 256;
return ( return (
<Stack <Box className={searchClasses.searchRoot}>
className="mediaRequests-stats-layout" {!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
h="100%" <Stack
gap="xs" className="mediaRequests-stats-layout"
p="sm" h="100%"
align="center" gap="xs"
justify="space-between" p="sm"
style={{ pointerEvents: isEditMode ? "none" : undefined }} align="center"
> justify="space-between"
<Stack gap={4} w="100%"> style={{ pointerEvents: isEditMode ? "none" : undefined }}
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}> >
{t("titles.stats.main")} <Stack gap={4} w="100%">
</Text> <Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%"> {t("titles.stats.main")}
{data.map((stat) => ( </Text>
<Grid.Col <Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)} {data.map((stat) => (
key={stat.name} <Grid.Col
span={isTiny ? 6 : 3} className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
> key={stat.name}
<Tooltip label={t(`titles.stats.${stat.name}`)}> span={isTiny ? 6 : 3}
<Card p={0} radius={board.itemRadius} className={classes.card}> >
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%"> <Tooltip label={t(`titles.stats.${stat.name}`)}>
<stat.icon className="mediaRequests-stats-stat-icon" size={16} /> <Card p={0} radius={board.itemRadius} className={classes.card}>
<Text className="mediaRequests-stats-stat-value" size="md"> <Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
{stat.number} <stat.icon className="mediaRequests-stats-stat-icon" size={16} />
<Text className="mediaRequests-stats-stat-value" size="md">
{stat.number}
</Text>
</Group>
</Card>
</Tooltip>
</Grid.Col>
))}
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
{requestStats.users.slice(0, 10).map((user) => (
<Card
component="a"
href={user.link}
target="_blank"
rel="noopener noreferrer"
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
)}
key={user.id}
p="xs"
radius={board.itemRadius}
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
<Group gap={4}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm">
{user.displayName}
</Text> </Text>
</Group> </Group>
</Card>
</Tooltip> <Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
</Grid.Col> {user.requestCount}
))}
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
{requestStats.users.slice(0, 10).map((user) => (
<Card
component="a"
href={user.link}
target="_blank"
rel="noopener noreferrer"
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
)}
key={user.id}
p="xs"
radius={board.itemRadius}
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
<Group gap={4}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm">
{user.displayName}
</Text> </Text>
</Group> </Group>
</Card>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}> ))}
{user.requestCount} </Stack>
</Text>
</Group>
</Card>
))}
</Stack> </Stack>
</Stack> </Stack>
</Stack> </Box>
); );
} }
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
const t = useScopedI18n("search.mode.media");
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={searchClasses.searchButton}
variant="light"
size="sm"
aria-label={t("action.search.label")}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
<IconSearch size={16} />
</ActionIcon>
</Tooltip>
);
};
+815 -783
View File
File diff suppressed because it is too large. Load diff
+46 -46
View File
@@ -6,7 +6,7 @@ catalog:
"@auth/core": ^0.41.2 "@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2 "@auth/drizzle-adapter": ^1.11.2
"@ctrl/deluge": ^8.0.0 "@ctrl/deluge": ^8.0.0
"@ctrl/qbittorrent": ^9.12.2 "@ctrl/qbittorrent": ^9.12.1
"@ctrl/transmission": ^7.4.0 "@ctrl/transmission": ^7.4.0
"@dnd-kit/core": ^6.3.1 "@dnd-kit/core": ^6.3.1
"@dnd-kit/modifiers": ^9.0.0 "@dnd-kit/modifiers": ^9.0.0
@@ -20,17 +20,17 @@ catalog:
"@immich/sdk": ^2.7.5 "@immich/sdk": ^2.7.5
"@jellyfin/sdk": ^0.13.0 "@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0 "@kubernetes/client-node": ^1.4.0
"@mantine/charts": ^9.2.2 "@mantine/charts": ^9.2.1
"@mantine/colors-generator": ^9.2.2 "@mantine/colors-generator": ^9.2.1
"@mantine/core": ^9.2.2 "@mantine/core": ^9.2.1
"@mantine/dates": ^9.2.2 "@mantine/dates": ^9.2.1
"@mantine/dropzone": ^9.2.2 "@mantine/dropzone": ^9.2.1
"@mantine/form": ^9.2.2 "@mantine/form": ^9.2.1
"@mantine/hooks": ^9.2.2 "@mantine/hooks": ^9.2.1
"@mantine/modals": ^9.2.2 "@mantine/modals": ^9.2.1
"@mantine/notifications": ^9.2.2 "@mantine/notifications": ^9.2.1
"@mantine/spotlight": ^9.2.2 "@mantine/spotlight": ^9.2.1
"@mantine/tiptap": ^9.2.2 "@mantine/tiptap": ^9.2.1
"@next/eslint-plugin-next": 16.2.6 "@next/eslint-plugin-next": 16.2.6
"@octokit/auth-app": ^8.2.0 "@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0 "@paralleldrive/cuid2": ^3.3.0
@@ -43,34 +43,34 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1 "@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11 "@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0 "@tabler/icons-react": ^3.44.0
"@tanstack/react-query": ^5.100.14 "@tanstack/react-query": ^5.100.13
"@tanstack/react-query-devtools": ^5.100.14 "@tanstack/react-query-devtools": ^5.100.13
"@tanstack/react-query-next-experimental": ^5.100.14 "@tanstack/react-query-next-experimental": ^5.100.13
"@testcontainers/mysql": ^12.0.1 "@testcontainers/mysql": ^12.0.0
"@testcontainers/postgresql": ^12.0.1 "@testcontainers/postgresql": ^12.0.0
"@testcontainers/redis": ^12.0.1 "@testcontainers/redis": ^12.0.0
"@tiptap/extension-color": 3.24.0 "@tiptap/extension-color": 3.23.6
"@tiptap/extension-highlight": 3.24.0 "@tiptap/extension-highlight": 3.23.6
"@tiptap/extension-image": 3.24.0 "@tiptap/extension-image": 3.23.6
"@tiptap/extension-link": ^3.24.0 "@tiptap/extension-link": ^3.23.6
"@tiptap/extension-placeholder": ^3.24.0 "@tiptap/extension-placeholder": ^3.23.6
"@tiptap/extension-table": 3.24.0 "@tiptap/extension-table": 3.23.6
"@tiptap/extension-table-cell": 3.24.0 "@tiptap/extension-table-cell": 3.23.6
"@tiptap/extension-table-header": 3.24.0 "@tiptap/extension-table-header": 3.23.6
"@tiptap/extension-table-row": 3.24.0 "@tiptap/extension-table-row": 3.23.6
"@tiptap/extension-task-item": 3.24.0 "@tiptap/extension-task-item": 3.23.6
"@tiptap/extension-task-list": 3.24.0 "@tiptap/extension-task-list": 3.23.6
"@tiptap/extension-text-align": 3.24.0 "@tiptap/extension-text-align": 3.23.6
"@tiptap/extension-text-style": 3.24.0 "@tiptap/extension-text-style": 3.23.6
"@tiptap/extension-underline": 3.24.0 "@tiptap/extension-underline": 3.23.6
"@tiptap/react": ^3.24.0 "@tiptap/react": ^3.23.6
"@tiptap/starter-kit": ^3.24.0 "@tiptap/starter-kit": ^3.23.6
"@trpc/client": ^11.17.0 "@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0 "@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0 "@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0 "@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.17.0 "@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.9.16 "@turbo/gen": ^2.9.14
"@types/adm-zip": 0.5.8 "@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0 "@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13 "@types/better-sqlite3": 7.6.13
@@ -109,7 +109,7 @@ catalog:
croner: 10.0.1 croner: 10.0.1
cross-env: ^10.1.0 cross-env: ^10.1.0
crypto-js: ^4.2.0 crypto-js: ^4.2.0
dayjs: ^1.11.21 dayjs: ^1.11.20
deepmerge: 4.3.1 deepmerge: 4.3.1
dns-caching: ^0.2.9 dns-caching: ^0.2.9
dockerode: ^5.0.0 dockerode: ^5.0.0
@@ -124,30 +124,30 @@ catalog:
flag-icons: ^7.5.0 flag-icons: ^7.5.0
glob: ^13.0.6 glob: ^13.0.6
ical.js: ^2.2.1 ical.js: ^2.2.1
ioredis: 5.11.0 ioredis: 5.10.1
isomorphic-dompurify: ^3.15.0 isomorphic-dompurify: ^3.14.0
jotai: ^2.20.0 jotai: ^2.20.0
jsdom: ^29.1.1 jsdom: ^29.1.1
json5: ^2.2.3 json5: ^2.2.3
ldapts: 8.1.8 ldapts: 8.1.8
lodash.clonedeep: ^4.5.0 lodash.clonedeep: ^4.5.0
mantine-react-table: npm:mantine-react-table-open@9.0.3 mantine-react-table: 2.0.0-beta.9
maria2: ^0.4.1 maria2: ^0.4.1
mysql2: 3.22.4 mysql2: 3.22.3
next: 16.2.6 next: 16.2.6
next-auth: 5.0.0-beta.31 next-auth: 5.0.0-beta.31
next-intl: 4.13.0 next-intl: 4.12.0
node-loader: ^2.1.0 node-loader: ^2.1.0
octokit: ^5.0.5 octokit: ^5.0.5
oxfmt: ^0.52.0 oxfmt: ^0.51.0
oxlint: ^1.67.0 oxlint: ^1.66.0
pg: ^8.21.0 pg: ^8.21.0
postcss-preset-mantine: ^1.18.0 postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0 prismjs: ^1.30.0
proxmox-api: 1.1.1 proxmox-api: 1.1.1
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6 react-dom: 19.2.6
react-error-boundary: ^6.1.2 react-error-boundary: ^6.1.1
react-markdown: ^10.1.0 react-markdown: ^10.1.0
react-quill-new: ^3.8.3 react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1 react-simple-code-editor: ^0.14.1
@@ -158,12 +158,12 @@ catalog:
superjson: 2.2.6 superjson: 2.2.6
svgson: ^5.3.1 svgson: ^5.3.1
swagger-ui-react: ^5.32.6 swagger-ui-react: ^5.32.6
testcontainers: ^12.0.1 testcontainers: ^12.0.0
timezones-ical-library: ^2.2.0 timezones-ical-library: ^2.2.0
trpc-to-openapi: ^3.3.0 trpc-to-openapi: ^3.3.0
tsdav: ^2.2.2 tsdav: ^2.2.2
tsx: 4.20.4 tsx: 4.20.4
turbo: ^2.9.16 turbo: ^2.9.14
typescript: ^5.9.3 typescript: ^5.9.3
undici: 7.24.6 undici: 7.24.6
use-deep-compare-effect: ^1.8.1 use-deep-compare-effect: ^1.8.1
-11
View File
@@ -1,11 +0,0 @@
{
"meierschlumpf@gmail.com": "manuel-rw",
"thomas@ajnart.fr": "ajnart",
"omariqbal97@gmail.com": "omariqbal",
"kyle.grieder@gmail.com": "kylegrieder",
"oupsman@oupsman.fr": "Oupsman",
"pitschi@posteo.de": "pitschi",
"github@xela.codes": "itzTheMeow",
"febrile42@gmail.com": "febrile42",
"nanewman01@gmail.com": "NicolasNewman"
}
-309
View File
@@ -1,309 +0,0 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
const REPO_ROOT = join(import.meta.dirname, "..");
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
const OVERRIDES_PATH = join(REPO_ROOT, "scripts/codeowners-overrides.json");
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
const END_MARKER = "# END GENERATED widget/integration owners";
const WIDGET_SKIP = new Set(["_inputs", "errors", "modals", "test"]);
const INTEGRATION_SKIP = new Set(["base", "interfaces", "mock"]);
const BOT_SUBSTRINGS = ["[bot]", "renovate", "homarr-update", "dependabot", "github-actions"];
const WIDGET_ROUTER_ALIASES: Record<string, string> = {
"anchor-note": "anchor-notes",
"media-releases": "media-release",
rssFeed: "rssFeed",
stocks: "stocks",
};
const INTEGRATION_CRON_ALIASES: Record<string, string> = {
homeassistant: "home-assistant",
"download-client": "downloads",
"media-organizer": "media-organizer",
"pi-hole": "dns-hole",
"smart-home": "home-assistant",
};
const WIDGET_TO_INTEGRATION_DIR: Record<string, string> = {
"anchor-note": "anchor",
};
const NESTED_WIDGET_ROUTER_PARENT: Record<string, string> = {
"dns-hole": "dns-hole",
immich: "immich",
"media-requests": "media-requests",
"network-controller": "network-controller",
minecraft: "minecraft",
"smart-home": "smart-home",
};
type Overrides = Record<string, string>;
const isBotEmail = (email: string) => BOT_SUBSTRINGS.some((fragment) => email.toLowerCase().includes(fragment));
const gitEmailForPath = (repoPath: string) => {
const strategies = [
["log", "--grep=feat", "-i", "--format=%ae", "--reverse", "-1", "--", repoPath],
["log", "--diff-filter=A", "--format=%ae", "--reverse", "-1", "--", repoPath],
["log", "--format=%ae", "--reverse", "-1", "--", repoPath],
] as const;
for (const args of strategies) {
const result = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
const email = (result.stdout ?? "").trim();
if (email && !isBotEmail(email)) return email;
}
return "";
};
const noreplyHandle = (email: string) => {
const match = email.match(/^\d+\+([^@]+)@users\.noreply\.github\.com$/i);
return match?.[1] ?? null;
};
const resolveHandle = (email: string, overrides: Overrides) => {
const fromNoreply = noreplyHandle(email);
if (fromNoreply) return fromNoreply;
return overrides[email] ?? null;
};
const hasLeafMarker = (dirPath: string) => {
const names = readdirSync(dirPath);
const leafPatterns = ["-integration.ts", "component.tsx", "index.ts", "index.tsx"];
return names.some((name) => leafPatterns.some((suffix) => name.endsWith(suffix) || name === suffix));
};
const discoverLeafDirs = (base: string, skip: Set<string>) => {
const absoluteBase = join(REPO_ROOT, base);
const leaves: string[] = [];
for (const name of readdirSync(absoluteBase).sort()) {
const dirPath = join(absoluteBase, name);
if (!statSync(dirPath).isDirectory() || skip.has(name)) continue;
if (hasLeafMarker(dirPath)) {
leaves.push(join(base, name));
continue;
}
const nestedLeaves = readdirSync(dirPath)
.filter((sub) => statSync(join(dirPath, sub)).isDirectory())
.filter((sub) => hasLeafMarker(join(dirPath, sub)))
.map((sub) => join(base, name, sub));
leaves.push(...(nestedLeaves.length > 0 ? nestedLeaves : [join(base, name)]));
}
return leaves;
};
const relFromBase = (fullPath: string, base: string) => relative(join(REPO_ROOT, base), join(REPO_ROOT, fullPath));
const widgetRouterKey = (widgetRel: string) => {
const direct = WIDGET_ROUTER_ALIASES[widgetRel];
if (direct) return direct;
const segment = widgetRel.includes("/") ? (widgetRel.split("/")[0] ?? widgetRel) : widgetRel;
const parentAlias = NESTED_WIDGET_ROUTER_PARENT[segment];
const segmentAlias = WIDGET_ROUTER_ALIASES[segment];
return parentAlias ?? segmentAlias ?? segment;
};
const integrationCronKey = (integrationRel: string) => {
const segment = integrationRel.includes("/") ? (integrationRel.split("/")[0] ?? integrationRel) : integrationRel;
return INTEGRATION_CRON_ALIASES[segment] ?? INTEGRATION_CRON_ALIASES[integrationRel] ?? segment;
};
const existingFile = (repoPath: string) => {
const full = join(REPO_ROOT, repoPath);
return existsSync(full) ? `/${repoPath}` : null;
};
const widgetSatellites = (widgetRel: string) => {
const routerKey = widgetRouterKey(widgetRel);
const cronKey = integrationCronKey(widgetRel);
return [
existingFile(`packages/api/src/router/widgets/${routerKey}.ts`),
existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`),
existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`),
].filter((path): path is string => path !== null);
};
const integrationSatellites = (integrationRel: string) => {
const cronKey = integrationCronKey(integrationRel);
return [
existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`),
existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`),
].filter((path): path is string => path !== null);
};
const dirCodeownersPath = (repoDir: string) => `/${repoDir}/`;
const integrationRelForWidget = (widgetRel: string) => {
const mapped = WIDGET_TO_INTEGRATION_DIR[widgetRel] ?? WIDGET_TO_INTEGRATION_DIR[widgetRel.split("/")[0] ?? ""];
if (mapped) return mapped;
const direct = join("packages/integrations/src", widgetRel);
if (existsSync(join(REPO_ROOT, direct))) return widgetRel;
const segment = widgetRel.split("/")[0] ?? widgetRel;
const segmentPath = join("packages/integrations/src", segment);
if (existsSync(join(REPO_ROOT, segmentPath))) return segment;
return null;
};
const collectPathsForWidget = (widgetRel: string) => {
const paths = new Set<string>([dirCodeownersPath(`packages/widgets/src/${widgetRel}`), ...widgetSatellites(widgetRel)]);
const integrationRel = integrationRelForWidget(widgetRel);
if (integrationRel) {
paths.add(dirCodeownersPath(`packages/integrations/src/${integrationRel}`));
for (const satellite of integrationSatellites(integrationRel)) paths.add(satellite);
}
return [...paths];
};
const collectPathsForIntegration = (integrationRel: string) => {
const paths = new Set<string>([
dirCodeownersPath(`packages/integrations/src/${integrationRel}`),
...integrationSatellites(integrationRel),
]);
const widgetPath = join(REPO_ROOT, "packages/widgets/src", integrationRel);
if (existsSync(widgetPath)) {
paths.add(dirCodeownersPath(`packages/widgets/src/${integrationRel}`));
for (const satellite of widgetSatellites(integrationRel)) paths.add(satellite);
}
return [...paths];
};
const loadOverrides = async () => JSON.parse(await readFile(OVERRIDES_PATH, "utf8")) as Overrides;
const resolveGithubUser = async (email: string, overrides: Overrides) => {
const fromOverrides = resolveHandle(email, overrides);
if (fromOverrides) return fromOverrides;
const token = process.env.GITHUB_TOKEN;
if (!token) return null;
const response = await fetch(`https://api.github.com/search/users?q=${encodeURIComponent(email)}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) return null;
const data = (await response.json()) as { items: { login: string }[] };
return data.items[0]?.login ?? null;
};
type OwnerEntry = { primary: string; line: string };
const buildOwnerEntries = async (overrides: Overrides) => {
const entries: OwnerEntry[] = [];
const unresolved: { path: string; email: string }[] = [];
const claimedDirs = new Set<string>();
const addUnit = async (repoDir: string, collectPaths: (rel: string) => string[]) => {
if (claimedDirs.has(repoDir)) return;
const rel =
repoDir.startsWith("packages/widgets/") ?
relFromBase(repoDir, "packages/widgets/src")
: relFromBase(repoDir, "packages/integrations/src");
const email = gitEmailForPath(`${repoDir}/`);
if (!email) {
unresolved.push({ path: repoDir, email: "" });
return;
}
const handle = await resolveGithubUser(email, overrides);
if (!handle) {
unresolved.push({ path: repoDir, email });
return;
}
const paths = collectPaths(rel).sort();
const primary = paths[0] ?? `/${repoDir}/`;
entries.push({ primary, line: `${paths.join(" ")} @${handle}` });
claimedDirs.add(repoDir);
for (const path of paths) {
if (!path.endsWith("/")) continue;
claimedDirs.add(path.slice(1, -1));
}
};
for (const widgetDir of discoverLeafDirs("packages/widgets/src", WIDGET_SKIP)) {
await addUnit(widgetDir, collectPathsForWidget);
}
for (const integrationDir of discoverLeafDirs("packages/integrations/src", INTEGRATION_SKIP)) {
await addUnit(integrationDir, collectPathsForIntegration);
}
entries.sort((a, b) => a.primary.localeCompare(b.primary));
return { entries, unresolved };
};
const formatGeneratedBlock = (entries: OwnerEntry[]) => {
const lines = entries.map((entry) => entry.line);
return [BEGIN_MARKER, ...lines, END_MARKER].join("\n");
};
const replaceGeneratedSection = (content: string, generatedBlock: string) => {
const startIndex = content.indexOf(BEGIN_MARKER);
const endIndex = content.indexOf(END_MARKER);
if (startIndex === -1 || endIndex === -1) {
return `${content.trimEnd()}\n\n${generatedBlock}\n`;
}
const before = content.slice(0, startIndex).trimEnd();
const after = content.slice(endIndex + END_MARKER.length).trimStart();
const middle = generatedBlock;
const chunks = [before, middle, after].filter((chunk) => chunk.length > 0);
return `${chunks.join("\n\n")}\n`;
};
const main = async () => {
const overrides = await loadOverrides();
const { entries, unresolved } = await buildOwnerEntries(overrides);
const generatedBlock = formatGeneratedBlock(entries);
const current = await readFile(CODEOWNERS_PATH, "utf8");
const next = replaceGeneratedSection(current, generatedBlock);
const checkOnly = process.argv.includes("--check");
if (unresolved.length > 0) {
console.warn("Unresolved ownership units (add to scripts/codeowners-overrides.json):");
for (const entry of unresolved) console.warn(` ${entry.path} (${entry.email || "no email"})`);
}
if (checkOnly) {
if (next !== current) {
console.error("CODEOWNERS is out of date. Run: pnpm scripts:generate-codeowners");
process.exit(1);
}
return;
}
await writeFile(CODEOWNERS_PATH, next, "utf8");
console.log(`Updated CODEOWNERS with ${entries.length} feature units.`);
};
await main();
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
echo "==> Local CODEOWNERS checks"
pnpm exec tsx ./scripts/generate-codeowners.mts --check
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-homarr-labs/homarr}"
if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)"
export GITHUB_TOKEN
fi
pnpm exec tsx ./scripts/validate-codeowners.mts
echo "==> act: validate job (requires Docker)"
act workflow_dispatch -W .github/workflows/update-codeowners.yml -j validate \
-s GITHUB_TOKEN="${GITHUB_TOKEN:-}"
-145
View File
@@ -1,145 +0,0 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const REPO_ROOT = join(import.meta.dirname, "..");
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
const END_MARKER = "# END GENERATED widget/integration owners";
const UNSUPPORTED_PATTERN_CHECKS: { label: string; pattern: RegExp }[] = [
{ label: "negation (!)", pattern: /^\s*!/ },
{ label: "character range ([ ])", pattern: /^\s*[^#]*\[[^\]]+\]/ },
];
const parseOwners = (line: string) => [...line.matchAll(/@[\w-]+(?:\/[\w-]+)?/g)].map((match) => match[0]);
const parsePatterns = (line: string) => {
const withoutOwners = line.replace(/@[\w-]+(?:\/[\w-]+)?/g, "").trim();
return withoutOwners.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("#"));
};
const validateLine = (line: string, lineNumber: number) => {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) return [];
const errors: string[] = [];
for (const check of UNSUPPORTED_PATTERN_CHECKS) {
if (check.pattern.test(line)) {
errors.push(`line ${lineNumber}: unsupported CODEOWNERS syntax (${check.label})`);
}
}
const owners = parseOwners(line);
const patterns = parsePatterns(line);
if (patterns.length === 0 && owners.length === 0) return errors;
if (patterns.length > 0 && owners.length === 0) return errors;
if (owners.length > 0 && patterns.length === 0) {
errors.push(`line ${lineNumber}: owners without file patterns`);
}
for (const pattern of patterns) {
const allowedGlobal = pattern === "*";
const hasValidShape =
allowedGlobal || pattern.startsWith("/") || pattern.includes("*") || pattern.includes(".");
if (!hasValidShape) {
errors.push(`line ${lineNumber}: pattern "${pattern}" should start with / or contain a wildcard`);
}
}
for (const owner of owners) {
if (!owner.startsWith("@")) {
errors.push(`line ${lineNumber}: invalid owner "${owner}"`);
}
}
return errors;
};
const validateStructure = (content: string) => {
const errors: string[] = [];
const lines = content.split("\n");
if (!content.includes("* @")) {
errors.push("missing default owner rule (* @team or * @user) — last matching pattern wins per GitHub docs");
}
const starIndex = lines.findIndex((line) => /^\*(\s+|$)/.test(line.trim()));
const beginIndex = lines.findIndex((line) => line.includes(BEGIN_MARKER));
const endIndex = lines.findIndex((line) => line.includes(END_MARKER));
if (beginIndex === -1 || endIndex === -1) {
errors.push("missing generated CODEOWNERS markers");
return errors;
}
if (starIndex !== -1 && beginIndex !== -1 && starIndex > beginIndex) {
errors.push("global * rule must appear before generated feature rules so per-folder owners override maintainers");
}
if (endIndex <= beginIndex) {
errors.push("END GENERATED marker must follow BEGIN GENERATED marker");
}
for (let index = 0; index < lines.length; index += 1) {
const lineErrors = validateLine(lines[index] ?? "", index + 1);
errors.push(...lineErrors);
}
return errors;
};
const validateGithubApi = async () => {
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
const repository = process.env.GITHUB_REPOSITORY ?? "homarr-labs/homarr";
if (!token) {
console.warn("Skipping GitHub API CODEOWNERS check (GITHUB_TOKEN not set).");
return;
}
const response = await fetch(`https://api.github.com/repos/${repository}/codeowners/errors`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub API codeowners/errors failed: ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as { errors: unknown[] };
const apiErrors = payload.errors ?? [];
if (apiErrors.length > 0) {
console.error("GitHub reported CODEOWNERS syntax errors:");
console.error(JSON.stringify(apiErrors, null, 2));
process.exit(1);
}
console.log("GitHub API: no CODEOWNERS syntax errors.");
};
const main = async () => {
const content = await readFile(CODEOWNERS_PATH, "utf8");
const errors = validateStructure(content);
if (errors.length > 0) {
console.error("CODEOWNERS validation failed:");
for (const error of errors) {
console.error(` ${error}`);
}
process.exit(1);
}
console.log("CODEOWNERS structure validation passed.");
await validateGithubApi();
};
await main();