Merge branch 'develop' into feature/user-search-lookup

This commit is contained in:
Arul Cornelious
2026-02-26 10:55:09 +00:00
committed by GitHub
75 changed files with 808 additions and 336 deletions

View File

@@ -45,8 +45,10 @@ module.exports = {
overrides: [
{
files: ['**/*.tsx'],
plugins: ['react'],
rules: {
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
},
},
],

View File

@@ -129,7 +129,7 @@ jobs:
build:
name: Build (per-arch, native runners)
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
if: github.ref == 'refs/heads/develop'
strategy:
matrix:
include:
@@ -237,7 +237,7 @@ jobs:
discord:
name: Send Discord Notification
needs: publish
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
if: always() && github.event_name != 'pull_request'
runs-on: ubuntu-24.04
steps:
- name: Determine Workflow Status

87
.github/workflows/create-tag.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Create tag
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
determine-tag-version:
name: Determine tag version
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
tag_version: ${{ steps.git-cliff.outputs.tag_version }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Install git-cliff
uses: taiki-e/install-action@cede0bb282aae847dfa8aacca3a41c86d973d4d7 # v2.68.1
with:
tool: git-cliff
- name: Get tag version
id: git-cliff
run: |
tag_version=$(git-cliff -c .github/cliff.toml --bumped-version --unreleased)
echo "Next tag version is ${tag_version}"
echo "tag_version=${tag_version}" >> "$GITHUB_OUTPUT"
create-tag:
name: Create tag
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
permissions:
contents: write
needs: determine-tag-version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_VERSION: ${{ needs.determine-tag-version.outputs.tag_version }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ssh-key: '${{ secrets.COMMIT_KEY }}'
- name: Pnpm Setup
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: 'package.json'
# For workflows with elevated privileges we recommend disabling automatic caching.
# https://github.com/actions/setup-node
package-manager-cache: false
- name: Configure git
run: |
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
- name: Bump package.json
run: npm version ${TAG_VERSION} --no-commit-hooks --no-git-tag-version
- name: Commit updated files
run: |
git add package.json
git commit -m 'chore(release): prepare ${TAG_VERSION}'
git push
- name: Create git tag
run: |
git tag ${TAG_VERSION}
git push origin ${TAG_VERSION}

View File

@@ -304,42 +304,3 @@ jobs:
run: gh release edit "${{ env.VERSION }}" --draft=false --repo "${{ github.repository }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
discord:
name: Send Discord Notification
needs: publish-release
if: always()
runs-on: ubuntu-24.04
steps:
- name: Determine status
id: status
run: |
case "${{ needs.publish-release.result }}" in
success) echo "status=Success" >> $GITHUB_OUTPUT; echo "colour=3066993" >> $GITHUB_OUTPUT ;;
failure) echo "status=Failure" >> $GITHUB_OUTPUT; echo "colour=15158332" >> $GITHUB_OUTPUT ;;
cancelled) echo "status=Cancelled" >> $GITHUB_OUTPUT; echo "colour=10181046" >> $GITHUB_OUTPUT ;;
*) echo "status=Skipped" >> $GITHUB_OUTPUT; echo "colour=9807270" >> $GITHUB_OUTPUT ;;
esac
- name: Send notification
run: |
WEBHOOK="${{ secrets.DISCORD_WEBHOOK }}"
PAYLOAD=$(cat <<EOF
{
"embeds": [{
"title": "${{ steps.status.outputs.status }}: ${{ github.workflow }}",
"color": ${{ steps.status.outputs.colour }},
"fields": [
{ "name": "Repository", "value": "[${{ github.repository }}](${{ github.server_url }}/${{ github.repository }})", "inline": true },
{ "name": "Ref", "value": "${{ github.ref }}", "inline": true },
{ "name": "Event", "value": "${{ github.event_name }}", "inline": true },
{ "name": "Triggered by", "value": "${{ github.actor }}", "inline": true },
{ "name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
]
}]
}
EOF
)
curl -sS -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$WEBHOOK" || true

View File

@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
[[ -n $HUSKY_BYPASS ]] || npx commitlint --edit $1
[ -n "$HUSKY_BYPASS" ] || npx commitlint --edit $1

View File

@@ -2,6 +2,7 @@
.next/
dist/
config/
cache/config.json
pnpm-lock.yaml
cypress/config/settings.cypress.json
.github
@@ -17,4 +18,4 @@ public/*
**/charts
# Prettier breaks GitHub alert syntax in markdown
*.md
*.md

View File

@@ -33,5 +33,11 @@ module.exports = {
rangeEnd: 0,
},
},
{
files: 'cache/config.json',
options: {
rangeEnd: 0, // default: Infinity
},
},
],
};

View File

@@ -6,6 +6,12 @@ All help is welcome and greatly appreciated! If you would like to contribute to
> [!IMPORTANT]
>
> Automated AI-generated contributions without human review are not allowed and will be rejected.
> This is an open-source project maintained by volunteers.
> We do not have the resources to review pull requests that could have been avoided with proper human oversight.
> While we have no issue with contributors using AI tools as an aid, it is your responsibility as a contributor to ensure that all submissions are carefully reviewed and meet our quality standards.
> Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban.
>
> If you are using **any kind of AI assistance** to contribute to Seerr,
> it must be disclosed in the pull request.

View File

@@ -3,7 +3,7 @@ kubeVersion: '>=1.23.0-0'
name: seerr-chart
description: Seerr helm chart for Kubernetes
type: application
version: 3.1.0
version: 3.2.0
# renovate: image=ghcr.io/seerr-team/seerr
appVersion: 'v3.0.1'
maintainers:

View File

@@ -1,6 +1,6 @@
# seerr-chart
![Version: 3.1.0](https://img.shields.io/badge/Version-3.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.0.1](https://img.shields.io/badge/AppVersion-v3.0.1-informational?style=flat-square)
![Version: 3.2.0](https://img.shields.io/badge/Version-3.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.0.1](https://img.shields.io/badge/AppVersion-v3.0.1-informational?style=flat-square)
Seerr helm chart for Kubernetes
@@ -77,6 +77,18 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| probes.readinessProbe | object | `{}` | Configure readiness probe |
| probes.startupProbe | string | `nil` | Configure startup probe |
| resources | object | `{}` | |
| route.main.additionalRules | list | `[]` | |
| route.main.annotations | object | `{}` | |
| route.main.apiVersion | string | `"gateway.networking.k8s.io/v1"` | Set the route apiVersion, e.g. gateway.networking.k8s.io/v1 or gateway.networking.k8s.io/v1alpha2 |
| route.main.enabled | bool | `false` | Enables or disables the Gateway API route |
| route.main.filters | list | `[]` | |
| route.main.hostnames | list | `[]` | |
| route.main.httpsRedirect | bool | `false` | To redirect to HTTPS, create a new route object under the main route and enable this option. This should only be used with HTTP-like routes, such as HTTPRoute or GRPCRoute. [Reference]( https://gateway-api.sigs.k8s.io/guides/http-redirect-rewrite/ ) |
| route.main.kind | string | `"HTTPRoute"` | Set the route kind. Note that experimental kinds require changing `apiVersion` |
| route.main.labels | object | `{}` | |
| route.main.matches[0].path.type | string | `"PathPrefix"` | |
| route.main.matches[0].path.value | string | `"/"` | |
| route.main.parentRefs | list | `[]` | |
| securityContext.allowPrivilegeEscalation | bool | `false` | |
| securityContext.capabilities.drop[0] | string | `"ALL"` | |
| securityContext.privileged | bool | `false` | |

View File

@@ -0,0 +1,50 @@
{{- range $name, $route := .Values.route }}
{{- if $route.enabled }}
---
apiVersion: {{ $route.apiVersion | default "gateway.networking.k8s.io/v1" }}
kind: {{ $route.kind | default "HTTPRoute" }}
metadata:
name: {{ template "seerr.fullname" $ }}{{ if ne $name "main" }}-{{ $name }}{{ end }}
labels:
{{- include "seerr.labels" $ | nindent 4 }}
{{- with $route.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $route.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with $route.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $route.hostnames }}
hostnames:
{{- tpl (toYaml .) $ | nindent 4 }}
{{- end }}
rules:
{{- with $route.additionalRules }}
{{- tpl (toYaml .) $ | nindent 4 }}
{{- end }}
{{- if $route.httpsRedirect }}
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
{{- else }}
- backendRefs:
- name: {{ include "seerr.fullname" $ }}
port: {{ $.Values.service.port }}
{{- with $route.filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $route.matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -105,6 +105,44 @@ ingress:
# hosts:
# - chart-example.local
route:
main:
# -- Enables or disables the Gateway API route
enabled: false
# -- Set the route apiVersion, e.g. gateway.networking.k8s.io/v1 or gateway.networking.k8s.io/v1alpha2
apiVersion: gateway.networking.k8s.io/v1
# -- Set the route kind.
# Note that experimental kinds require changing `apiVersion`
kind: HTTPRoute
annotations: {}
labels: {}
parentRefs: []
# - name: my-gateway
# namespace: gateway
# sectionName: https
hostnames: []
# - seerr.example.com
additionalRules: []
filters: []
matches:
- path:
type: PathPrefix
value: /
# -- To redirect to HTTPS, create a new route object under the main route and enable this option.
# This should only be used with HTTP-like routes, such as HTTPRoute or GRPCRoute.
# [Reference]( https://gateway-api.sigs.k8s.io/guides/http-redirect-rewrite/ )
httpsRedirect: false
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little

View File

@@ -30,7 +30,7 @@ If your PostgreSQL server is configured to accept TCP connections, you can speci
```dotenv
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
DB_HOST="localhost" # (optional) The host (URL) of the database. The default is "localhost".
DB_HOST=localhost # (optional) The host (URL) of the database. The default is "localhost".
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
DB_USER= # (required) Username used to connect to the database.
DB_PASS= # (required) Password of the user used to connect to the database.

View File

@@ -0,0 +1,111 @@
---
title: Synology (Advanced)
description: Install Seerr on Synology NAS using SynoCommunity
sidebar_position: 5
---
# Synology
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
:::warning
This method is not recommended for most users. It is intended for advanced users who are using Synology NAS.
:::
## Prerequisites
- Synology NAS running **DSM 7.2** or later
- 64-bit architecture (x86_64 or ARMv8)
- [SynoCommunity package source](https://synocommunity.com/) added to Package Center
## Adding the SynoCommunity Package Source
If you haven't already added SynoCommunity to your Package Center:
1. Open **Package Center** in DSM
2. Click **Settings** in the top-right corner
3. Go to the **Package Sources** tab
4. Click **Add**
5. Enter the following:
- **Name**: `SynoCommunity`
- **Location**: `https://packages.synocommunity.com`
6. Click **OK**
## Installation
1. In **Package Center**, search for **Seerr**
2. Click **Install**
3. Follow the installation wizard prompts
4. Package Center will automatically install any required dependencies (Node.js v22)
### Access Seerr
Once installed, access Seerr at:
```
http://<your-synology-ip>:5055
```
You can also click the **Open** button in Package Center or find Seerr in the DSM main menu.
## Configuration
Seerr's configuration files are stored at:
```
/var/packages/seerr/var/config
```
:::info
The Seerr package runs as a dedicated service user managed by DSM. No manual permission configuration is required.
:::
## Managing the Service
You can start, stop, and restart Seerr from **Package Center** → Find Seerr → Use the action buttons.
## Updating
When a new version is available:
1. Open **Package Center**
2. Go to **Installed** packages
3. Find **Seerr** and click **Update** if available
:::tip
Enable automatic updates in Package Center settings to keep Seerr up to date.
:::
## Troubleshooting
### Viewing Logs
Seerr logs are located at `/var/packages/seerr/var/config/logs` and can be accessed using:
- **File Browser** package (recommended for most users)
- SSH (advanced users)
### Port Conflicts
Seerr uses port 5055. If this port is already in use:
- **Docker containers**: Remap the conflicting container to a different port
- **Other packages**: The conflicting package will need to be uninstalled as Seerr's port cannot be changed
SynoCommunity ensures there are no port conflicts with other SynoCommunity packages or official Synology packages.
### Package Won't Start
Ensure Node.js v22 is installed and running by checking its status in **Package Center**.
## Uninstallation
1. Open **Package Center**
2. Find **Seerr** in your installed packages
3. Click **Uninstall**
:::caution
Uninstalling will remove the application but preserve your configuration data by default. Select "Remove data" during uninstallation if you want a complete removal.
:::

View File

@@ -4,12 +4,6 @@ description: Install Seerr using TrueNAS
sidebar_position: 4
---
# TrueNAS
:::danger
This method has not yet been updated for Seerr and is currently a work in progress.
You can follow the ongoing work on this issue https://github.com/truenas/apps/issues/3374.
:::
<!--
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
@@ -17,4 +11,7 @@ Third-party installation methods are maintained by the community. The Seerr team
:::warning
This method is not recommended for most users. It is intended for advanced users who are using TrueNAS distribution.
:::
-->
## Installation
Go to the 'Apps' menu, click the 'Discover Apps' button in the top right, search for 'Seerr' in the search bar, and install the app.

View File

@@ -4,6 +4,9 @@ description: Install Seerr using Unraid
sidebar_position: 3
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Unraid
:::warning
@@ -14,76 +17,160 @@ Third-party installation methods are maintained by the community. The Seerr team
This method is not recommended for most users. It is intended for advanced users who are using Unraid.
:::
## Template installation
If an official Unraid Community Applications template for Seerr isn't available in your catalog, you can install Seerr manually using Unraid's Docker UI.
Several templates from Unraid Community are available right now
- [Unraid Community Apps](https://unraid.net/community/apps#community-apps-iframe)
## Fresh Installation
In case you still want to do it manually, please follow the instructions below.
### 1. Create the config directory
## Manual Installation
:::note
Seerr is now rootless. Unraid typically runs Docker containers as `nobody:users` (UID 99, GID 100), but Seerr now runs internally as UID 1000, GID 1000. This creates a permission mismatch.
:::
Before proceeding, choose which installation method best suits your server:
:::info
**If migrating**: Copy your existing Jellyseerr/Overseerr config files (e.g., from `/mnt/user/appdata/overseerr/` or `/mnt/user/appdata/jellyseerr`) to `/mnt/user/appdata/seerr`, then apply the permissions below
:::
**Seerr Default** (`UID 1000:1000`) — Runs with the container's native `node` user. Matches the official image, but may break SMB share access, trigger "Fix Common Problems" warnings, and cause issues with backup plugins (such as CA Appdata Backup) and network integrations (such as Tailscale).
Open the Unraid terminal and run:
**Unraid Default** (`UID 99:100`) — Runs with Unraid's native `nobody:users` ownership. Follows Unraid best practices, but overrides Seerr's built-in rootless user and may cause compatibility issues with future Seerr updates.
```bash
mkdir -p /mnt/user/appdata/seerr
chown -R 1000:1000 /mnt/user/appdata/seerr
```
<Tabs groupId="unraid-permissions" queryString>
<TabItem value="seerr-default" label="Seerr Default">
### 2. Add the Docker container
### 1. Create the config directory
Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following:
Open the Unraid terminal and run:
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Registry URL** (optional) | `https://ghcr.io` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
```bash
mkdir -p /mnt/user/appdata/seerr
```
Then click **Add another Path, Port, Variable** to add:
### 2. Set folder permissions
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
```bash
chown -R 1000:1000 /mnt/user/appdata/seerr
```
**Path:**
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/seerr` |
### 3. Add the Docker container
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
:::warning
The **Extra Parameters** field is critical. You **must** add `--init --restart=unless-stopped` (see table below). Without `--init`, the container will not handle signals properly or shut down cleanly. Without `--restart=unless-stopped`, the container will not automatically restart after a reboot.
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
To see the **Extra Parameters** field, click **Basic View** in the top-right corner of the template page to switch to the advanced editor.
:::
Click **Apply** to create and start the container.
Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following:
### 3. Access Seerr
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init --restart=unless-stopped` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
Open the WebUI at `http://<your-unraid-ip>:5055` and follow the setup wizard.
Then click **Add another Path, Port, Variable** to add:
:::info
The `--init` flag in **Extra Parameters** is required. Seerr does not include its own init process, so `--init` ensures proper signal handling and clean container shutdowns.
:::
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path:**
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/seerr` |
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
Click **Apply** to create and start the container.
### 4. Access Seerr
Open the WebUI at `http://<your-unraid-ip>:5055` and follow the setup wizard.
</TabItem>
<TabItem value="unraid-default" label="Unraid Default">
### 1. Create the config directory
Open the Unraid terminal and run:
```bash
mkdir -p /mnt/user/appdata/seerr
```
### 2. Set folder permissions
```bash
chown -R 99:100 /mnt/user/appdata/seerr
```
### 3. Add the Docker container
:::warning
The **Extra Parameters** field is critical. You **must** add `--init --restart=unless-stopped --user 99:100` (see table below). Without `--init`, the container will not handle signals properly or shut down cleanly. Without `--restart=unless-stopped`, the container will not automatically restart after a reboot. The `--user 99:100` parameter runs the container process with Unraid's default UID/GID and avoids permission errors accessing your shares without changing host folder ownership.
To see the **Extra Parameters** field, click **Basic View** in the top-right corner of the template page to switch to the advanced editor.
:::
Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following:
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init --restart=unless-stopped --user 99:100` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
Then click **Add another Path, Port, Variable** to add:
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path:**
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/seerr` |
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
Click **Apply** to create and start the container.
### 4. Access Seerr
Open the WebUI at `http://<your-unraid-ip>:5055` and follow the setup wizard.
</TabItem>
</Tabs>

View File

@@ -200,6 +200,11 @@ Summary of changes :
</Tabs>
## Third-party installation methods
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
### Nix
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
@@ -210,7 +215,42 @@ See https://aur.archlinux.org/packages/seerr
### TrueNAS
Waiting for https://github.com/truenas/apps/issues/3374
Refer to [Seerr TrueNAS Documentation](/getting-started/third-parties/truenas), all of our examples have been updated to reflect the below change.
<Tabs groupId="truenas-migration" queryString>
<TabItem value="hostpath" label="Host Path">
**This guide describes how to migrate from Host Path storage (not ixVolume).**
1. Stop Jellyseerr/Overseerr
2. Install Seerr and use the same Host Path storage that was used by Jellyseerr/Overseerr
3. Start Seerr app
4. Delete Jellyseerr/Overseerr app
</TabItem>
<TabItem value="ixvolume" label="ixVolume">
**This guide describes how to migrate from ixVolume storage (not Host Path).**
1. Stop Jellyseerr/Overseerr
2. Create a dataset for Seerr
If your apps normally store data under something like:
```
/mnt/storage/<app-name>
```
then create a dataset named:
```
storage/seerr
```
resulting in:
```
/mnt/storage/seerr
```
3. Copy ixVolume Data
Open System Settings → Shell, or SSH into your TrueNAS server as root and run :
```bash
rsync -av /mnt/.ix-apps/app_mounts/jellyseerr/ /mnt/storage/seerr/
```
4. Install Seerr and use the same Host Path storage that was created before (`/mnt/storage/seerr/config` in our example)
5. Start Seerr app
6. Delete Jellyseerr/Overseerr app
</TabItem>
</Tabs>
### Unraid
@@ -218,63 +258,23 @@ Refer to [Seerr Unraid Documentation](/getting-started/third-parties/unraid), al
Seerr will automatically migrate your existing Overseerr or Jellyseerr data on first startup. No manual database migration is needed.
1. Stop and remove the old Overseerr (or Jellyseerr) container from the Unraid **Docker** tab. Click the container icon, then **Stop**, then **Remove**. **⚠️ Do not delete the appdata folder ⚠️**
**1. Stop the existing container**
In the Unraid **Docker** tab, stop your Overseerr (or Jellyseerr) container.
**⚠️ Do not remove the container or delete the appdata folder yet ⚠️**
2. Back up your existing appdata folder:
**2. Copy existing data to Seerr appdata**
Open the Unraid terminal and copy your existing appdata folder into the new Seerr appdata directory:
```bash
cp -a /mnt/user/appdata/overseerr /mnt/user/appdata/overseerr-backup
cp -a /mnt/user/appdata/overseerr /mnt/user/appdata/seerr
```
3. Fix config folder permissions — Seerr runs as the `node` user (UID 1000) instead of root:
```bash
chown -R 1000:1000 /mnt/user/appdata/overseerr
```
For Jellyseerr users, replace `overseerr` with `jellyseerr` in the path above.
*(For Jellyseerr users, replace `overseerr` with `jellyseerr` in the paths).*
4. Add a new container in the Unraid **Docker** tab. Click **Add Container** and fill in the following:
**3. Set permissions and install Seerr**
Follow the [Unraid Installation Guide](/getting-started/third-parties/unraid#2-set-folder-permissions), **starting from step 2** — this covers setting the correct folder permissions and adding the Docker container. The guide offers two permission methods (**Seerr Default** and **Unraid Default**), each with trade-offs — read the descriptions before choosing.
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Registry URL** (optional) | `https://ghcr.io` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
**4. Start the new Seerr app**
Start the newly created Seerr container. Check the container logs to confirm the automatic migration completed successfully.
Then click **Add another Path, Port, Variable** to add:
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path** — point this to your existing config folder:
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/overseerr` |
For Jellyseerr users, use `/mnt/user/appdata/jellyseerr`.
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
5. Click **Apply** to start the container. Check the container logs to confirm the automatic migration completed successfully.
:::tip
If you are using a reverse proxy (such as SWAG or Nginx Proxy Manager), update your proxy configuration to point to the new container name `seerr`. The default port remains `5055`.
:::
**5. Remove the old app**
Once you have confirmed Seerr is working properly and your data has successfully migrated, you can safely **Remove** the old Overseerr (or Jellyseerr) container from Unraid.

View File

@@ -19,7 +19,7 @@ Please check how to migrate to Seerr in our [migration guide](https://docs.seerr
Seerr brings several features that were previously available in Jellyseerr but missing from Overseerr. These additions improve flexibility, performance, and overall control for admins and power users:
* **Alternative media solution:** Added support for Jellyfin and Emby in addition to the existing Plex integration.
* **Alternative media solution:** Added support for Jellyfin and Emby as alternatives to Plex. Only one integration can be used at a time.
* **PostgreSQL support**: In addition to SQLite, you can now opt in to using a PostgreSQL database.
* **Blocklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users.
* **Override rules**: Adjust default request settings based on conditions such as user, tag, or other criteria.

View File

@@ -16,7 +16,12 @@ const config: Config = {
deploymentBranch: 'gh-pages',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
i18n: {
defaultLocale: 'en',

View File

@@ -5,6 +5,7 @@
"packageManager": "pnpm@10.24.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"postinstall": "next telemetry disable",
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build",

View File

@@ -1,7 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import type { DVRSettings } from '@server/lib/settings';
import { getSettings, type DVRSettings } from '@server/lib/settings';
export interface SystemStatus {
version: string;
@@ -92,14 +92,14 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
apiKey,
cacheName,
apiName,
timeout = 10000,
}: {
url: string;
apiKey: string;
cacheName: AvailableCacheIds;
apiName: string;
timeout?: number;
}) {
const timeout = getSettings().network.apiRequestTimeout;
super(
url,
{

View File

@@ -64,16 +64,8 @@ export interface RadarrMovie {
}
class RadarrAPI extends ServarrBase<{ movieId: number }> {
constructor({
url,
apiKey,
timeout,
}: {
url: string;
apiKey: string;
timeout?: number;
}) {
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr', timeout });
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
}
public getMovies = async (): Promise<RadarrMovie[]> => {
@@ -184,10 +176,27 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}
if (movie.id) {
logger.info(
'Movie is already monitored in Radarr. Skipping add and returning success',
{ label: 'Radarr' }
);
// Movie exists and is already monitored
logger.info('Movie is already monitored in Radarr.', {
label: 'Radarr',
movieId: movie.id,
movieTitle: movie.title,
hasFile: movie.hasFile,
});
// If searchNow is requested and movie doesn't have a file, trigger search
if (options.searchNow && !movie.hasFile) {
logger.info(
'Triggering search for existing monitored movie without file',
{
label: 'Radarr',
movieId: movie.id,
movieTitle: movie.title,
}
);
this.searchMovie(movie.id);
}
return movie;
}

View File

@@ -111,16 +111,8 @@ class SonarrAPI extends ServarrBase<{
episodeId: number;
episode: EpisodeResult;
}> {
constructor({
url,
apiKey,
timeout,
}: {
url: string;
apiKey: string;
timeout?: number;
}) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr', timeout });
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}
public async getSeries(): Promise<SonarrSeries[]> {

View File

@@ -206,6 +206,19 @@ class Media {
Object.assign(this, init);
}
public resetServiceData(): void {
this.serviceId = null;
this.serviceId4k = null;
this.externalServiceId = null;
this.externalServiceId4k = null;
this.externalServiceSlug = null;
this.externalServiceSlug4k = null;
this.ratingKey = null;
this.ratingKey4k = null;
this.jellyfinMediaId = null;
this.jellyfinMediaId4k = null;
}
@AfterLoad()
public setPlexUrls(): void {
const { machineId, webAppUrl } = getSettings().plex;

View File

@@ -397,6 +397,13 @@ class JellyfinScanner
episodes: totalStandard,
episodes4k: total4k,
});
} else {
processableSeasons.push({
seasonNumber: season.season_number,
totalEpisodes: season.episode_count,
episodes: 0,
episodes4k: 0,
});
}
}

View File

@@ -430,6 +430,13 @@ class PlexScanner
mediaIds.tmdbId = tmdbMedia.id;
}
if (mediaIds.tvdbId && !mediaIds.tmdbId) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: mediaIds.tvdbId,
});
mediaIds.tmdbId = show.id;
}
// Cache GUIDs
guidCache.data.set(plexitem.ratingKey, mediaIds);

View File

@@ -1,6 +1,12 @@
import { getMetadataProvider } from '@server/api/metadata';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {
@@ -102,6 +108,15 @@ class SonarrScanner
}
const tmdbId = tvShow.id;
const metadataProvider = tvShow.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
if (!(metadataProvider instanceof TheMovieDb)) {
tvShow = await metadataProvider.getTvShow({ tvId: tmdbId });
}
const settings = getSettings();
const filteredSeasons = sonarrSeries.seasons.filter(

View File

@@ -171,6 +171,7 @@ export interface NetworkSettings {
trustProxy: boolean;
proxy: ProxySettings;
dnsCache: DnsCacheSettings;
apiRequestTimeout: number;
}
interface PublicSettings {
@@ -593,6 +594,7 @@ class Settings {
forceMinTtl: 0,
forceMaxTtl: -1,
},
apiRequestTimeout: 10000,
},
migrations: [],
};

View File

@@ -45,7 +45,7 @@ class WatchlistSync {
[
Permission.AUTO_REQUEST,
Permission.AUTO_REQUEST_MOVIE,
Permission.AUTO_APPROVE_TV,
Permission.AUTO_REQUEST_TV,
],
{ type: 'or' }
)
@@ -70,13 +70,33 @@ class WatchlistSync {
response.items.map((i) => i.tmdbId)
);
const watchlistTmdbIds = response.items.map((i) => i.tmdbId);
const requestRepository = getRepository(MediaRequest);
const existingAutoRequests = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('request.requestedBy = :userId', { userId: user.id })
.andWhere('request.isAutoRequest = true')
.andWhere('media.tmdbId IN (:...tmdbIds)', { tmdbIds: watchlistTmdbIds })
.getMany();
const autoRequestedTmdbIds = new Set(
existingAutoRequests
.filter((r) => r.media != null)
.map((r) => `${r.media.mediaType}:${r.media.tmdbId}`)
);
const unavailableItems = response.items.filter(
// If we can find watchlist items in our database that are also available, we should exclude them
(i) =>
!autoRequestedTmdbIds.has(
`${i.type === 'show' ? MediaType.TV : MediaType.MOVIE}:${i.tmdbId}`
) &&
!mediaItems.find(
(m) =>
m.tmdbId === i.tmdbId &&
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
(m.status === MediaStatus.BLOCKLISTED ||
(m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
)
);

View File

@@ -174,7 +174,12 @@ mediaRoutes.delete(
where: { id: Number(req.params.id) },
});
await mediaRepository.remove(media);
if (media.status === MediaStatus.BLOCKLISTED) {
media.resetServiceData();
await mediaRepository.save(media);
} else {
await mediaRepository.remove(media);
}
return res.status(204).send();
} catch (e) {
@@ -310,12 +315,12 @@ mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
if (media.ratingKey) {
const watchStats = await tautulli.getMediaWatchStats(media.ratingKey);
const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey);
const plexIds = watchUsers.map((u) => u.user_id);
if (!plexIds.length) plexIds.push(-1);
const users = await userRepository
.createQueryBuilder('user')
.where('user.plexId IN (:...plexIds)', {
plexIds: watchUsers.map((u) => u.user_id),
})
.where('user.plexId IN (:...plexIds)', { plexIds })
.getMany();
const playCount =
@@ -342,12 +347,12 @@ mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
const watchUsers4k = await tautulli.getMediaWatchUsers(
media.ratingKey4k
);
const plexIds4k = watchUsers4k.map((u) => u.user_id);
if (!plexIds4k.length) plexIds4k.push(-1);
const users = await userRepository
.createQueryBuilder('user')
.where('user.plexId IN (:...plexIds)', {
plexIds: watchUsers4k.map((u) => u.user_id),
})
.where('user.plexId IN (:...plexIds)', { plexIds: plexIds4k })
.getMany();
const playCount =

View File

@@ -494,13 +494,16 @@ settingsRoutes.get(
thumb: string;
}[] = [];
const plexIds = plexUsers.map((plexUser) => plexUser.id);
const plexEmails = plexUsers.map((plexUser) =>
plexUser.email.toLowerCase()
);
if (!plexIds.length) plexIds.push('-1');
if (!plexEmails.length) plexEmails.push('@');
const existingUsers = await qb
.where('user.plexId IN (:...plexIds)', {
plexIds: plexUsers.map((plexUser) => plexUser.id),
})
.orWhere('user.email IN (:...plexEmails)', {
plexEmails: plexUsers.map((plexUser) => plexUser.email.toLowerCase()),
})
.where('user.plexId IN (:...plexIds)', { plexIds })
.orWhere('user.email IN (:...plexEmails)', { plexEmails })
.getMany();
await Promise.all(

View File

@@ -28,7 +28,7 @@ const AppDataWarning = () => {
<Alert
title={intl.formatMessage(messages.dockerVolumeMissingDescription, {
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
<code className="bg-gray-800/50">{msg}</code>
),
appDataPath: data.appDataPath,
})}

View File

@@ -441,13 +441,13 @@ const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
)}
<div className="card-field">
{item.mediaType === 'movie' ? (
<div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600 bg-opacity-80 shadow-md">
<div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600/80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.movie)}
</div>
</div>
) : (
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600/80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.tvshow)}
</div>

View File

@@ -12,8 +12,7 @@ interface AlertProps {
const Alert = ({ title, children, type }: AlertProps) => {
let design = {
bgColor:
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
bgColor: 'border border-yellow-500 backdrop-blur bg-yellow-400/20',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
@@ -22,8 +21,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
switch (type) {
case 'info':
design = {
bgColor:
'border border-indigo-500 backdrop-blur bg-indigo-400 bg-opacity-20',
bgColor: 'border border-indigo-500 backdrop-blur bg-indigo-400/20',
titleColor: 'text-gray-100',
textColor: 'text-gray-300',
svg: <InformationCircleIcon className="h-5 w-5" />,

View File

@@ -31,27 +31,25 @@ const Badge = (
switch (badgeType) {
case 'danger':
badgeStyle.push(
'bg-red-600 bg-opacity-80 border-red-500 border !text-red-100'
);
badgeStyle.push('bg-red-600/80 border-red-500 border !text-red-100');
if (href) {
badgeStyle.push('hover:bg-red-500 bg-opacity-100');
badgeStyle.push('hover:bg-red-500');
}
break;
case 'warning':
badgeStyle.push(
'bg-yellow-500 bg-opacity-80 border-yellow-500 border !text-yellow-100'
'bg-yellow-500/80 border-yellow-500 border !text-yellow-100'
);
if (href) {
badgeStyle.push('hover:bg-yellow-500 hover:bg-opacity-100');
badgeStyle.push('hover:bg-yellow-500');
}
break;
case 'success':
badgeStyle.push(
'bg-green-500 bg-opacity-80 border border-green-500 !text-green-100'
'bg-green-500/80 border border-green-500 !text-green-100'
);
if (href) {
badgeStyle.push('hover:bg-green-500 hover:bg-opacity-100');
badgeStyle.push('hover:bg-green-500');
}
break;
case 'dark':
@@ -68,10 +66,10 @@ const Badge = (
break;
default:
badgeStyle.push(
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
'bg-indigo-500/80 border border-indigo-500 !text-indigo-100'
);
if (href) {
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
badgeStyle.push('hover:bg-indigo-500');
}
}

View File

@@ -52,22 +52,22 @@ function Button<P extends ElementTypes = 'button'>(
switch (buttonType) {
case 'primary':
buttonStyle.push(
'text-white border border-indigo-500 bg-indigo-600 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-opacity-100 active:border-indigo-700'
'text-white border border-indigo-500 bg-indigo-600/80 hover:bg-indigo-600 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-600 active:border-indigo-700'
);
break;
case 'danger':
buttonStyle.push(
'text-white bg-red-600 bg-opacity-80 border-red-500 hover:bg-opacity-100 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700'
'text-white bg-red-600/80 border-red-500 hover:bg-red-600 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700'
);
break;
case 'warning':
buttonStyle.push(
'text-white border border-yellow-500 bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700'
'text-white border border-yellow-500 bg-yellow-500/80 hover:bg-yellow-500 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-500 active:border-yellow-700'
);
break;
case 'success':
buttonStyle.push(
'text-white bg-green-500 bg-opacity-80 border-green-500 hover:bg-opacity-100 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-opacity-100 active:border-green-700'
'text-white bg-green-500/80 border-green-500 hover:bg-green-500 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-500 active:border-green-700'
);
break;
case 'ghost':
@@ -77,7 +77,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
default:
buttonStyle.push(
'text-gray-200 bg-gray-800 bg-opacity-80 border-gray-600 hover:text-white hover:bg-gray-700 hover:border-gray-600 group-hover:text-white group-hover:bg-gray-700 group-hover:border-gray-600 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-700 active:border-gray-600'
'text-gray-200 bg-gray-800/80 border-gray-600 hover:text-white hover:bg-gray-700 hover:border-gray-600 group-hover:text-white group-hover:bg-gray-700 group-hover:border-gray-600 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-700 active:border-gray-600'
);
}

View File

@@ -34,9 +34,9 @@ const ButtonWithDropdown = ({
break;
default:
styleClasses.mainButtonClasses +=
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
' bg-indigo-600/80 border-indigo-500 hover:bg-indigo-600 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses +=
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
' bg-indigo-600/80 border-indigo-500 hover:bg-indigo-600 active:bg-indigo-600 focus:ring-blue';
}
const TriggerElement = props.as ?? 'button';

View File

@@ -59,7 +59,7 @@ const DropdownItems = ({
className={[
'absolute right-0 z-40 -mr-1 mt-2 w-56 origin-top-right rounded-md p-1 shadow-lg',
dropdownType === 'ghost'
? 'border border-gray-700 bg-gray-800 bg-opacity-80 backdrop-blur'
? 'border border-gray-700 bg-gray-800/80 backdrop-blur'
: 'bg-indigo-600',
className,
].join(' ')}
@@ -95,7 +95,7 @@ const Dropdown = ({
'button-md inline-flex h-full items-center space-x-2 rounded-md border px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none',
buttonType === 'ghost'
? 'border-gray-600 bg-transparent hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
: 'focus:ring-blue border-indigo-500 bg-indigo-600 bg-opacity-80 hover:border-indigo-500 hover:bg-opacity-100 active:border-indigo-700 active:bg-indigo-700',
: `focus:ring-blue border-indigo-500 bg-indigo-600/80 hover:border-indigo-500 hover:bg-indigo-600 active:border-indigo-700 active:bg-indigo-700`,
className,
].join(' ')}
ref={buttonRef}

View File

@@ -91,7 +91,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition.Child
appear
as="div"
className="fixed bottom-0 left-0 right-0 top-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
className="fixed bottom-0 left-0 right-0 top-0 z-50 flex h-full w-full items-center justify-center bg-gray-800/70"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"

View File

@@ -24,13 +24,13 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
className={`${
checked ? 'bg-indigo-500' : 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
);
};

View File

@@ -46,7 +46,7 @@ const SlideOver = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={`fixed inset-0 z-50 overflow-hidden bg-gray-800 bg-opacity-70`}
className={`fixed inset-0 z-50 overflow-hidden bg-gray-800/70`}
onClick={() => onClose()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@@ -71,7 +71,7 @@ const SlideOver = ({
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<div className="flex h-full flex-col rounded-lg bg-gray-800/80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<header className="space-y-1 border-b border-gray-700 px-4 py-4">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-overseerr text-2xl font-bold leading-7">

View File

@@ -24,7 +24,7 @@ const StatusBadgeMini = ({
shrink = false,
}: StatusBadgeMiniProps) => {
const badgeStyle = [
`rounded-full bg-opacity-80 shadow-md ${
`rounded-full shadow-md ${
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
}`,
];
@@ -34,34 +34,34 @@ const StatusBadgeMini = ({
switch (status) {
case MediaStatus.PROCESSING:
badgeStyle.push(
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
'bg-indigo-500/80 border-indigo-400 ring-indigo-400 text-indigo-100'
);
indicatorIcon = <ClockIcon />;
break;
case MediaStatus.AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
'bg-green-500/80 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <CheckCircleIcon />;
break;
case MediaStatus.PENDING:
badgeStyle.push(
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
'bg-yellow-500/80 border-yellow-400 ring-yellow-400 text-yellow-100'
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.BLOCKLISTED:
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
badgeStyle.push('bg-red-500/80 border-white ring-white text-white');
indicatorIcon = <EyeSlashIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
'bg-green-500/80 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <MinusSmallIcon />;
break;
case MediaStatus.DELETED:
badgeStyle.push('bg-red-500 border-red-400 ring-red-400 text-red-100');
badgeStyle.push('bg-red-500/80 border-red-400 ring-red-400 text-red-100');
indicatorIcon = <TrashIcon />;
break;
}

View File

@@ -508,7 +508,7 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
typeof errors.data === 'string' && (
<div className="error">{errors.data}</div>
)}
<div className="flex-1"></div>
<div className="flex-1" />
{resultCount === 0 ? (
<Tooltip content={intl.formatMessage(messages.needresults)}>
<div>

View File

@@ -159,7 +159,7 @@ const Discover = () => {
<button
onClick={() => setIsEditing(true)}
data-testid="discover-start-editing"
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700/90 p-3 text-gray-400 shadow transition-all hover:bg-gray-700"
>
<PencilIcon className="h-full w-full" />
</button>
@@ -172,7 +172,7 @@ const Discover = () => {
leave="transition duration-300"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed left-0 right-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-x-3 sm:space-y-0"
className="safe-shift-edit-menu fixed left-0 right-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800/80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-x-3 sm:space-y-0"
>
<Button
buttonType="default"

View File

@@ -20,8 +20,8 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
canExpand ? 'w-full' : 'w-56 sm:w-72'
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
isHovered
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
? 'scale-105 bg-gray-700/100 ring-gray-500'
: 'scale-100 bg-gray-800/80 ring-gray-700'
} overflow-hidden rounded-xl bg-cover bg-center`}
onMouseEnter={() => {
setHovered(true);
@@ -43,8 +43,8 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
fill
/>
<div
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
className={`absolute inset-0 z-10 h-full w-full transition duration-300 ${
isHovered ? 'bg-gray-800/10' : 'bg-gray-800/30'
}`}
/>
<div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl">
@@ -58,7 +58,7 @@ const GenreCardPlaceholder = () => {
return (
<div
className={`relative h-32 w-56 animate-pulse rounded-xl bg-gray-700 sm:h-40 sm:w-72`}
></div>
/>
);
};

View File

@@ -244,7 +244,7 @@ const CreateIssueModal = ({
<RadioGroup.Label className="sr-only">
Select an Issue
</RadioGroup.Label>
<div className="-space-y-px overflow-hidden rounded-md bg-gray-800 bg-opacity-30">
<div className="-space-y-px overflow-hidden rounded-md bg-gray-800/30">
{issueOptions.map((setting, index) => (
<RadioGroup.Option
key={`issue-type-${setting.issueType}`}
@@ -256,7 +256,7 @@ const CreateIssueModal = ({
? 'rounded-bl-md rounded-br-md'
: '',
checked
? 'z-10 border border-indigo-500 bg-indigo-400 bg-opacity-20'
? 'z-10 border border-indigo-500 bg-indigo-400/20'
: 'border-gray-500',
'relative flex cursor-pointer border p-4 focus:outline-none'
)

View File

@@ -179,7 +179,7 @@ const MobileMenu = ({
leave="transition duration-500"
leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0"
className="absolute left-0 right-0 top-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
className="absolute left-0 right-0 top-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900/90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
>
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);
@@ -225,7 +225,7 @@ const MobileMenu = ({
);
})}
</Transition>
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800/90 backdrop-blur">
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
{filteredLinks
.slice(0, filteredLinks.length === 5 ? 5 : 4)

View File

@@ -24,7 +24,7 @@ const SearchInput = () => {
<input
id="search_field"
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
className="block w-full rounded-full border border-gray-600 bg-gray-900 bg-opacity-80 py-2 pl-10 text-white placeholder-gray-300 hover:border-gray-500 focus:border-gray-500 focus:bg-opacity-100 focus:placeholder-gray-400 focus:outline-none focus:ring-0 sm:text-base"
className="block w-full rounded-full border border-gray-600 bg-gray-900/80 py-2 pl-10 text-white placeholder-gray-300 hover:border-gray-500 focus:border-gray-500 focus:bg-gray-900 focus:placeholder-gray-400 focus:outline-none focus:ring-0 sm:text-base"
placeholder={intl.formatMessage(messages.searchPlaceholder)}
type="search"
autoComplete="off"

View File

@@ -163,7 +163,7 @@ const Sidebar = ({
leaveTo="opacity-0"
>
<div className="fixed inset-0">
<div className="absolute inset-0 bg-gray-900 opacity-90"></div>
<div className="absolute inset-0 bg-gray-900 opacity-90" />
</div>
</Transition.Child>
<Transition.Child

View File

@@ -74,7 +74,7 @@ const UserDropdown = () => {
appear
>
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="divide-y divide-gray-700 rounded-md bg-gray-800/80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2">
<CachedImage

View File

@@ -88,8 +88,8 @@ const Layout = ({ children }: LayoutProps) => {
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
<PullToRefresh />
<div
className={`searchbar fixed left-0 right-0 top-0 z-10 flex flex-shrink-0 bg-opacity-80 transition duration-300 ${
isScrolled ? 'bg-gray-700' : 'bg-transparent'
className={`searchbar fixed left-0 right-0 top-0 z-10 flex flex-shrink-0 transition duration-300 ${
isScrolled ? 'bg-gray-700/80' : 'bg-transparent'
} lg:left-64`}
style={{
backdropFilter: isScrolled ? 'blur(5px)' : undefined,

View File

@@ -154,7 +154,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
<div className="flex-grow"></div>
<div className="flex-grow" />
{baseUrl && (
<a
href={

View File

@@ -120,7 +120,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
typeof errors.password === 'string' && (
<div className="error">{errors.password}</div>
)}
<div className="flex-grow"></div>
<div className="flex-grow" />
{passwordResetEnabled && (
<Link
href="/resetpassword"

View File

@@ -169,7 +169,7 @@ const Login = () => {
</div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div
className="bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
className="bg-gray-800/50 shadow sm:rounded-lg"
style={{ backdropFilter: 'blur(5px)' }}
>
<>
@@ -241,11 +241,11 @@ const Login = () => {
{additionalLoginOptions.length > 0 &&
(loginFormVisible ? (
<div className="flex items-center py-5">
<div className="flex-grow border-t border-gray-600"></div>
<div className="flex-grow border-t border-gray-600" />
<span className="mx-2 flex-shrink text-sm text-gray-400">
{intl.formatMessage(messages.orsigninwith)}
</span>
<div className="flex-grow border-t border-gray-600"></div>
<div className="flex-grow border-t border-gray-600" />
</div>
) : (
<h2 className="mb-6 text-center text-lg font-bold text-neutral-200">

View File

@@ -317,7 +317,7 @@ const CollectionRequestModal = ({
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-700 bg-opacity-80 px-4 py-3">
<th className="w-16 bg-gray-700/80 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
@@ -340,19 +340,19 @@ const CollectionRequestModal = ({
className={`${
isAllParts() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
isAllParts() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</th>
<th className="bg-gray-700 bg-opacity-80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700/80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.movie)}
</th>
<th className="bg-gray-700 bg-opacity-80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700/80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.status)}
</th>
</tr>
@@ -423,7 +423,7 @@ const CollectionRequestModal = ({
? 'bg-indigo-500'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
@@ -435,7 +435,7 @@ const CollectionRequestModal = ({
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</td>
<td

View File

@@ -84,7 +84,7 @@ const SearchByNameModal = ({
onClick={() => handleClick(item.tvdbId)}
>
<div
className={`flex h-40 w-full items-center overflow-hidden rounded-xl border border-gray-700 bg-gray-700 bg-opacity-20 p-2 shadow backdrop-blur transition ${
className={`flex h-40 w-full items-center overflow-hidden rounded-xl border border-gray-700 bg-gray-700/20 p-2 shadow backdrop-blur transition ${
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
} `}
>

View File

@@ -529,7 +529,7 @@ const TvRequestModal = ({
<thead>
<tr>
<th
className={`w-16 bg-gray-700 bg-opacity-80 px-4 py-3 ${
className={`w-16 bg-gray-700/80 px-4 py-3 ${
!settings.currentSettings.partialRequestsEnabled &&
'hidden'
}`}
@@ -557,22 +557,22 @@ const TvRequestModal = ({
className={`${
isAllSeasons() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</th>
<th className="bg-gray-700 bg-opacity-80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700/80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.season)}
</th>
<th className="bg-gray-700 bg-opacity-80 px-5 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700/80 px-5 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.numberofepisodes)}
</th>
<th className="bg-gray-700 bg-opacity-80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700/80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.status)}
</th>
</tr>
@@ -649,7 +649,7 @@ const TvRequestModal = ({
? 'bg-indigo-500'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
@@ -662,7 +662,7 @@ const TvRequestModal = ({
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">

View File

@@ -65,7 +65,7 @@ const ResetPassword = () => {
</div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div
className="bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
className="bg-gray-800/50 shadow sm:rounded-lg"
style={{ backdropFilter: 'blur(5px)' }}
>
<div className="px-10 py-8">

View File

@@ -75,7 +75,7 @@ const ResetPassword = () => {
</div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div
className="bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
className="bg-gray-800/50 shadow sm:rounded-lg"
style={{ backdropFilter: 'blur(5px)' }}
>
<div className="px-10 py-8">

View File

@@ -228,7 +228,7 @@ const NotificationsTelegram = () => {
</a>
),
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
<code className="bg-gray-800/50">{msg}</code>
),
})}
</span>

View File

@@ -60,7 +60,7 @@ const SettingsAbout = () => {
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mt-6 rounded-md border border-indigo-500 bg-indigo-400 bg-opacity-20 p-4 backdrop-blur">
<div className="mt-6 rounded-md border border-indigo-500 bg-indigo-400/20 p-4 backdrop-blur">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className="h-5 w-5 text-gray-100" />
@@ -88,7 +88,7 @@ const SettingsAbout = () => {
<Alert
title={intl.formatMessage(messages.runningDevelop, {
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
<code className="bg-gray-800/50">{msg}</code>
),
})}
/>

View File

@@ -495,7 +495,7 @@ const SettingsJobs = () => {
<Table.TH>{intl.formatMessage(messages.jobname)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.jobtype)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.nextexecution)}</Table.TH>
<Table.TH></Table.TH>
<Table.TH />
</tr>
</thead>
<Table.TBody>
@@ -578,7 +578,7 @@ const SettingsJobs = () => {
<Table.TH>{intl.formatMessage(messages.cachekeys)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.cacheksize)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.cachevsize)}</Table.TH>
<Table.TH></Table.TH>
<Table.TH />
</tr>
</thead>
<Table.TBody>
@@ -639,7 +639,7 @@ const SettingsJobs = () => {
<Table.TH>
{intl.formatMessage(messages.dnscacheage)}
</Table.TH>
<Table.TH></Table.TH>
<Table.TH />
</tr>
</thead>
<Table.TBody>
@@ -744,7 +744,7 @@ const SettingsJobs = () => {
<p className="description">
{intl.formatMessage(messages.imagecacheDescription, {
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
<code className="bg-gray-800/50">{msg}</code>
),
appDataPath: appData ? appData.appDataPath : '/app/config',
})}

View File

@@ -245,7 +245,7 @@ const SettingsLogs = () => {
<p className="description">
{intl.formatMessage(messages.logsDescription, {
code: (msg: React.ReactNode) => (
<code className="whitespace-normal break-words bg-opacity-50">
<code className="whitespace-normal break-words bg-gray-800/50">
{msg}
</code>
),
@@ -314,7 +314,7 @@ const SettingsLogs = () => {
<Table.TH>{intl.formatMessage(messages.level)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.label)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.message)}</Table.TH>
<Table.TH></Table.TH>
<Table.TH />
</tr>
</thead>
<Table.TBody>

View File

@@ -56,6 +56,10 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
'Do NOT enable this if you are experiencing issues with DNS lookups',
dnsCacheForceMinTtl: 'DNS Cache Minimum TTL',
dnsCacheForceMaxTtl: 'DNS Cache Maximum TTL',
apiRequestTimeout: 'API Request Timeout',
apiRequestTimeoutTip:
'Maximum time (in seconds) to wait for responses from external services like Radarr/Sonarr. Set to 0 for no timeout.',
validationApiRequestTimeout: 'You must provide a valid timeout value',
});
const SettingsNetwork = () => {
@@ -91,6 +95,10 @@ const SettingsNetwork = () => {
.max(65535, intl.formatMessage(messages.validationProxyPort))
.required(intl.formatMessage(messages.validationProxyPort)),
}),
apiRequestTimeout: Yup.number()
.typeError(intl.formatMessage(messages.validationApiRequestTimeout))
.required(intl.formatMessage(messages.validationApiRequestTimeout))
.min(0, intl.formatMessage(messages.validationApiRequestTimeout)),
});
if (!data && !error) {
@@ -130,6 +138,10 @@ const SettingsNetwork = () => {
proxyPassword: data?.proxy?.password,
proxyBypassFilter: data?.proxy?.bypassFilter,
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
apiRequestTimeout:
data?.apiRequestTimeout !== undefined
? data.apiRequestTimeout / 1000
: 10,
}}
enableReinitialize
validationSchema={NetworkSettingsSchema}
@@ -154,6 +166,7 @@ const SettingsNetwork = () => {
bypassFilter: values.proxyBypassFilter,
bypassLocalAddresses: values.proxyBypassLocalAddresses,
},
apiRequestTimeout: Number(values.apiRequestTimeout) * 1000,
});
mutate('/api/v1/settings/public');
mutate('/api/v1/status');
@@ -341,6 +354,31 @@ const SettingsNetwork = () => {
</div>
</>
)}
<div className="form-row">
<label htmlFor="apiRequestTimeout" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.apiRequestTimeout)}
</span>
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.apiRequestTimeoutTip)}
</span>
</label>
<div className="form-input-area">
<Field
id="apiRequestTimeout"
name="apiRequestTimeout"
type="text"
inputMode="numeric"
className="short"
/>
</div>
{errors.apiRequestTimeout &&
touched.apiRequestTimeout &&
typeof errors.apiRequestTimeout === 'string' && (
<div className="error">{errors.apiRequestTimeout}</div>
)}
</div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">

View File

@@ -164,7 +164,7 @@ const Setup = () => {
<AppDataWarning />
<nav className="relative z-50">
<ul
className="divide-y divide-gray-600 rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 md:flex md:divide-y-0"
className="divide-y divide-gray-600 rounded-md border border-gray-600 bg-gray-800/50 md:flex md:divide-y-0"
style={{ backdropFilter: 'blur(5px)' }}
>
<SetupSteps
@@ -193,7 +193,7 @@ const Setup = () => {
/>
</ul>
</nav>
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800/50 p-4 text-white">
{currentStep === 1 && (
<div className="flex flex-col items-center pb-6">
<div className="mb-2 flex justify-center text-xl font-bold">

View File

@@ -137,12 +137,12 @@ const StatusBadge = ({
const badgeDownloadProgress = (
<div
className={`absolute left-0 top-0 z-10 flex h-full bg-opacity-80 ${
className={`absolute left-0 top-0 z-10 flex h-full ${
status === MediaStatus.DELETED
? 'bg-red-600'
? 'bg-red-600/80'
: status === MediaStatus.PROCESSING
? 'bg-indigo-500'
: 'bg-green-500'
? 'bg-indigo-500/80'
: 'bg-green-500/80'
} transition-all duration-200 ease-in-out`}
style={{
width: `${
@@ -168,8 +168,7 @@ const StatusBadge = ({
badgeType="success"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
inProgress && 'relative !bg-gray-700/80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
@@ -234,8 +233,7 @@ const StatusBadge = ({
badgeType="success"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
inProgress && 'relative !bg-gray-700/80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
@@ -300,8 +298,7 @@ const StatusBadge = ({
badgeType="primary"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
inProgress && 'relative !bg-gray-700/80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
@@ -388,8 +385,7 @@ const StatusBadge = ({
badgeType="danger"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
inProgress && 'relative !bg-gray-700/80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}

View File

@@ -341,10 +341,10 @@ const TitleCard = ({
/>
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 self-start rounded-full border bg-opacity-80 shadow-md ${
className={`pointer-events-none z-40 self-start rounded-full border shadow-md ${
mediaType === 'movie' || mediaType === 'collection'
? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600'
? 'border-blue-500 bg-blue-600/80'
: 'border-purple-600 bg-purple-600/80'
}`}
>
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
@@ -432,7 +432,7 @@ const TitleCard = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 z-40 flex items-center justify-center rounded-xl bg-gray-800 bg-opacity-75 text-white">
<div className="absolute inset-0 z-40 flex items-center justify-center rounded-xl bg-gray-800/75 text-white">
<Spinner className="h-10 w-10" />
</div>
</Transition>

View File

@@ -186,13 +186,13 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
@@ -230,7 +230,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
@@ -238,7 +238,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">

View File

@@ -152,13 +152,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
@@ -189,7 +189,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
/>
<span
aria-hidden="true"
className={`${
@@ -197,7 +197,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
/>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">

View File

@@ -53,7 +53,7 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
<span
className="absolute inset-0 rounded-full shadow-inner"
aria-hidden="true"
></span>
/>
</div>
</div>
<div className="pt-1.5">

View File

@@ -100,14 +100,16 @@ const UserLinkedAccountsSettings = () => {
);
await revalidateUser();
} catch (e) {
if (e?.response?.status === 401) {
setError(intl.formatMessage(messages.plexErrorUnauthorized));
} else if (e?.response?.status === 422) {
setError(intl.formatMessage(messages.plexErrorExists));
} else {
setError(intl.formatMessage(messages.errorUnknown));
switch (e?.response?.status) {
case 401:
setError(intl.formatMessage(messages.plexErrorUnauthorized));
break;
case 422:
setError(intl.formatMessage(messages.plexErrorExists));
break;
default:
setError(intl.formatMessage(messages.errorUnknown));
}
setError(intl.formatMessage(messages.errorUnknown));
}
};
@@ -210,7 +212,7 @@ const UserLinkedAccountsSettings = () => {
{accounts.map((acct, i) => (
<li
key={i}
className="flex items-center gap-4 overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6"
className="flex items-center gap-4 overflow-hidden rounded-lg bg-gray-800/50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6"
>
<div className="w-12">
{acct.type === LinkedAccountType.Plex ? (

View File

@@ -153,7 +153,7 @@ const UserProfile = () => {
)) && (
<div className="relative z-40">
<dl className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-3">
<div className="overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6">
<div className="overflow-hidden rounded-lg bg-gray-800/50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6">
<dt className="truncate text-sm font-bold text-gray-300">
{intl.formatMessage(messages.totalrequests)}
</dt>
@@ -173,7 +173,7 @@ const UserProfile = () => {
</dd>
</div>
<div
className={`overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ${
className={`overflow-hidden rounded-lg bg-gray-800/50 px-4 py-5 shadow ring-1 ${
quota.movie.restricted
? 'bg-gradient-to-t from-red-900 to-transparent ring-red-500'
: 'ring-gray-700'
@@ -228,7 +228,7 @@ const UserProfile = () => {
</dd>
</div>
<div
className={`overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ${
className={`overflow-hidden rounded-lg bg-gray-800/50 px-4 py-5 shadow ring-1 ${
quota.tv.restricted
? 'bg-gradient-to-t from-red-900 to-transparent ring-red-500'
: 'ring-gray-700'

View File

@@ -997,6 +997,8 @@
"components.Settings.SettingsMain.validationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.SettingsMain.youtubeUrl": "YouTube URL",
"components.Settings.SettingsMain.youtubeUrlTip": "Base URL for YouTube videos if a self-hosted YouTube instance is used.",
"components.Settings.SettingsNetwork.apiRequestTimeout": "API Request Timeout",
"components.Settings.SettingsNetwork.apiRequestTimeoutTip": "Maximum time (in seconds) to wait for responses from external services like Radarr/Sonarr. Set to 0 for no timeout.",
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
@@ -1026,6 +1028,7 @@
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Seerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsNetwork.validationApiRequestTimeout": "You must provide a valid timeout value",
"components.Settings.SettingsNetwork.validationDnsCacheMaxTtl": "You must provide a valid maximum TTL",
"components.Settings.SettingsNetwork.validationDnsCacheMinTtl": "You must provide a valid minimum TTL",
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",

View File

@@ -206,7 +206,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
<meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
></meta>
/>
<PWAHeader
applicationTitle={currentSettings.applicationTitle}
/>

View File

@@ -106,7 +106,7 @@
}
.service-error-banner {
@apply mb-2 flex items-center gap-2 rounded-md border border-yellow-500 bg-yellow-500 bg-opacity-20 px-3 py-2 text-sm text-yellow-200;
@apply mb-2 flex items-center gap-2 rounded-md border border-yellow-500 bg-yellow-500/20 px-3 py-2 text-sm text-yellow-200;
}
.slider-title {
@@ -346,7 +346,7 @@
}
button.input-action {
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white last:rounded-r-md sm:px-3.5;
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600/80 px-3 py-2 text-sm font-medium leading-5 text-white last:rounded-r-md sm:px-3.5;
}
button.input-action[disabled] {
@@ -354,7 +354,7 @@
}
button.input-action:not([disabled]) {
@apply transition duration-150 ease-in-out hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700;
@apply transition duration-150 ease-in-out hover:bg-indigo-600 active:bg-gray-100 active:text-gray-700;
}
.button-md :where(svg),