Compare commits
5 Commits
develop
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a4b8c1563 | ||
|
|
747b84d4c9 | ||
|
|
be58352925 | ||
|
|
e43777527e | ||
|
|
87dddbb879 |
3
.github/ISSUE_TEMPLATE/bug.yml
vendored
3
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a problem
|
||||
labels: ['awaiting triage']
|
||||
type: bug
|
||||
labels: ['bug', 'awaiting triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
63
.github/ISSUE_TEMPLATE/documentation.yml
vendored
63
.github/ISSUE_TEMPLATE/documentation.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: 📚 Documentation
|
||||
description: Report a docs problem or suggest a docs improvement
|
||||
title: "[Docs]: "
|
||||
labels: ["documentation", "awaiting triage"]
|
||||
type: task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve the docs!
|
||||
|
||||
Use this template for documentation issues (typos, unclear steps, missing info, outdated screenshots).
|
||||
For app bugs or feature ideas, please use the other templates.
|
||||
- type: input
|
||||
id: doc-location
|
||||
attributes:
|
||||
label: Page / Location
|
||||
description: Link to the docs page or the file/path (e.g. https://docs.seerr.dev/... or README.md)
|
||||
placeholder: "https://docs.seerr.dev/..."
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: doc-area
|
||||
attributes:
|
||||
label: Docs Area
|
||||
options:
|
||||
- docs site
|
||||
- migration guide
|
||||
- README / repo docs
|
||||
- API / integrations
|
||||
- other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What’s wrong / missing?
|
||||
description: Describe the issue in the docs.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggested-fix
|
||||
attributes:
|
||||
label: Suggested change
|
||||
description: If you know what should be changed, describe it (or paste proposed wording).
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our Code of Conduct.
|
||||
options:
|
||||
- label: I agree to follow Seerr's [Code of Conduct](https://github.com/seerr-team/seerr/blob/develop/CODE_OF_CONDUCT.md).
|
||||
required: true
|
||||
3
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
3
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest an idea
|
||||
labels: ['awaiting triage']
|
||||
type: feature
|
||||
labels: ['enhancement', 'awaiting triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
65
.github/ISSUE_TEMPLATE/maintenance.yml
vendored
65
.github/ISSUE_TEMPLATE/maintenance.yml
vendored
@@ -1,65 +0,0 @@
|
||||
name: 🧰 Maintenance / Chore
|
||||
description: CI, GitHub Actions, build, dependencies, refactors (non-feature work)
|
||||
title: "[Chore]: "
|
||||
labels: ["maintenance", "awaiting triage"]
|
||||
type: task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Maintainers / contributors: use this for internal tasks (CI, workflows, tooling, refactors).
|
||||
If you're reporting a user-facing bug or requesting a feature, use the other templates.
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- CI / GitHub Actions
|
||||
- build / packaging
|
||||
- dependencies
|
||||
- release process
|
||||
- refactor / tech debt
|
||||
- tooling / scripts
|
||||
- other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What needs doing and why?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: What does "done" look like?
|
||||
placeholder: |
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: related
|
||||
attributes:
|
||||
label: Related links
|
||||
description: PRs, failing workflow runs, logs, or relevant issues.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our Code of Conduct.
|
||||
options:
|
||||
- label: I agree to follow Seerr's [Code of Conduct](https://github.com/seerr-team/seerr/blob/develop/CODE_OF_CONDUCT.md).
|
||||
required: true
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -18,7 +18,7 @@ env:
|
||||
DOCKER_HUB: seerr/seerr
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build (per-arch, native runners)
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: publish
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Determine Workflow Status
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -20,7 +20,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/conflict_labeler.yml
vendored
2
.github/workflows/conflict_labeler.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: merge-conflict-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
87
.github/workflows/create-tag.yml
vendored
87
.github/workflows/create-tag.yml
vendored
@@ -1,87 +0,0 @@
|
||||
---
|
||||
# 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}
|
||||
2
.github/workflows/cypress.yml
vendored
2
.github/workflows/cypress.yml
vendored
@@ -28,7 +28,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: cypress-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -15,7 +15,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/docs-link-check.yml
vendored
2
.github/workflows/docs-link-check.yml
vendored
@@ -25,7 +25,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: docs-link-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/helm.yml
vendored
2
.github/workflows/helm.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: helm-charts
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/lint-helm-charts.yml
vendored
2
.github/workflows/lint-helm-charts.yml
vendored
@@ -18,7 +18,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: charts-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/preview.yml
vendored
2
.github/workflows/preview.yml
vendored
@@ -15,7 +15,7 @@ env:
|
||||
DOCKER_HUB: seerr/seerr
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: preview-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -304,3 +304,42 @@ 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
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: renovate-helm-hooks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
28
.github/workflows/semantic-pr.yml
vendored
28
.github/workflows/semantic-pr.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: "Semantic PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR Title
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: write
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: close-stale-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/test-docs-deploy.yml
vendored
2
.github/workflows/test-docs-deploy.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: docs-pr-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/trivy-scan.yml
vendored
2
.github/workflows/trivy-scan.yml
vendored
@@ -16,7 +16,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: trivy-scan-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -6,12 +6,6 @@ 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.
|
||||
|
||||
@@ -128,7 +122,7 @@ Steps:
|
||||
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](/../../issues) to avoid multiple people working on the same thing.
|
||||
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- Pull requests with titles or commits not following this standard will **not** be merged. PR titles are automatically checked for compliance.
|
||||
- Pull requests with commits not following this standard will **not** be merged.
|
||||
- Please make meaningful commits, or squash them prior to opening a pull request.
|
||||
- Do not squash commits once people have begun reviewing your changes.
|
||||
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
|
||||
|
||||
@@ -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.0.2
|
||||
# renovate: image=ghcr.io/seerr-team/seerr
|
||||
appVersion: 'v3.0.1'
|
||||
maintainers:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# seerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Seerr helm chart for Kubernetes
|
||||
|
||||
@@ -44,10 +44,9 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| affinity | object | `{}` | |
|
||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"existingClaim":"","name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
||||
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
|
||||
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
|
||||
| config.persistence.existingClaim | string | `""` | Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created |
|
||||
| config.persistence.name | string | `""` | Config name |
|
||||
| config.persistence.size | string | `"5Gi"` | Size of persistent disk |
|
||||
| config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. |
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{{- if not .Values.config.persistence.existingClaim -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -23,4 +22,3 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: "{{ .Values.config.persistence.size }}"
|
||||
{{- end -}}
|
||||
@@ -103,7 +103,7 @@ spec:
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.config.persistence.existingClaim }}{{ .Values.config.persistence.existingClaim }}{{- else }}{{ include "seerr.configPersistenceName" . }}{{- end }}
|
||||
claimName: {{ include "seerr.configPersistenceName" . }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -86,8 +86,6 @@ config:
|
||||
# -- Name of the permanent volume to reference in the claim.
|
||||
# Can be used to bind to existing volumes.
|
||||
volumeName: ''
|
||||
# -- Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created
|
||||
existingClaim: ''
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
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.
|
||||
:::
|
||||
@@ -4,6 +4,12 @@ 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.
|
||||
:::
|
||||
@@ -11,7 +17,4 @@ 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.
|
||||
-->
|
||||
|
||||
@@ -21,14 +21,6 @@ If an official Unraid Community Applications template for Seerr isn't available
|
||||
|
||||
### 1. Create the config directory
|
||||
|
||||
:::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.
|
||||
:::
|
||||
|
||||
:::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
|
||||
:::
|
||||
|
||||
Open the Unraid terminal and run:
|
||||
|
||||
```bash
|
||||
@@ -86,4 +78,4 @@ Open the WebUI at `http://<your-unraid-ip>:5055` and follow the setup wizard.
|
||||
|
||||
:::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.
|
||||
:::
|
||||
:::
|
||||
@@ -210,42 +210,7 @@ See https://aur.archlinux.org/packages/seerr
|
||||
|
||||
### TrueNAS
|
||||
|
||||
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>
|
||||
Waiting for https://github.com/truenas/apps/issues/3374
|
||||
|
||||
### Unraid
|
||||
|
||||
@@ -312,4 +277,4 @@ For Jellyseerr users, use `/mnt/user/appdata/jellyseerr`.
|
||||
|
||||
:::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`.
|
||||
:::
|
||||
:::
|
||||
@@ -6,22 +6,18 @@ sidebar_position: 2
|
||||
|
||||
# Web Push
|
||||
|
||||
The web push notification agent enables you and your users to receive Seerr notifications in a supported browser.
|
||||
|
||||
This notification agent does not require any configuration, but is not enabled in Seerr
|
||||
|
||||
:::warning
|
||||
Web push notifications require a secure connection to your Seerr instance. Refer to the [Reverse Proxy](/extending-seerr/reverse-proxy) documentation for more information.
|
||||
:::
|
||||
|
||||
The web push notification agent enables you and your users to receive Seerr notifications in a supported browser. This offers a native notification experience without the need to install an app.
|
||||
To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**. You and your users will then be prompted to allow notifications in your web browser.
|
||||
|
||||
This notification agent does not require any configuration, but is not enabled by default in Seerr.
|
||||
Users can opt out of these notifications, or customize the notification types they would like to subscribe to, in their user settings.
|
||||
|
||||
To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**.
|
||||
|
||||
You and your users have the option to enable web push notifications by going to your **User Profile → Edit Settings → Notifications → Web Push → Enable web push**. Here you can also customize the notifications you'd like to receive.
|
||||
|
||||
:::info[Mobile Users]
|
||||
For Web Push notifications to work on mobile you need to add Seerr to your home screen as progressive web app (PWA).
|
||||
:::
|
||||
|
||||
:::info[iOS Users]
|
||||
On iOS you may need to enable the Safari notifications feature flag by going to **Settings → Safari → Advanced → Feature Flags** and enabling "Notifications".
|
||||
:::info
|
||||
Web push notifications offer a native notification experience without the need to install an app.
|
||||
:::
|
||||
|
||||
@@ -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 as alternatives to Plex. Only one integration can be used at a time.
|
||||
* **Alternative media solution:** Added support for Jellyfin and Emby in addition to the existing Plex integration.
|
||||
* **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.
|
||||
|
||||
@@ -16,12 +16,7 @@ const config: Config = {
|
||||
deploymentBranch: 'gh-pages',
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
|
||||
markdown: {
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
},
|
||||
},
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
|
||||
291
seerr-api.yml
291
seerr-api.yml
@@ -577,21 +577,9 @@ components:
|
||||
example: false
|
||||
baseUrl:
|
||||
type: string
|
||||
activeProfileId:
|
||||
type: number
|
||||
example: 1
|
||||
activeProfileName:
|
||||
type: string
|
||||
example: 720p/1080p
|
||||
activeDirectory:
|
||||
type: string
|
||||
example: '/movies'
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
minimumAvailability:
|
||||
type: string
|
||||
example: 'In Cinema'
|
||||
isDefault:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -610,11 +598,7 @@ components:
|
||||
- port
|
||||
- apiKey
|
||||
- useSsl
|
||||
- activeProfileId
|
||||
- activeProfileName
|
||||
- activeDirectory
|
||||
- is4k
|
||||
- minimumAvailability
|
||||
- isDefault
|
||||
SonarrSettings:
|
||||
type: object
|
||||
@@ -640,31 +624,6 @@ components:
|
||||
example: false
|
||||
baseUrl:
|
||||
type: string
|
||||
activeProfileId:
|
||||
type: number
|
||||
example: 1
|
||||
activeProfileName:
|
||||
type: string
|
||||
example: 720p/1080p
|
||||
activeDirectory:
|
||||
type: string
|
||||
example: '/tv/'
|
||||
activeLanguageProfileId:
|
||||
type: number
|
||||
example: 1
|
||||
activeAnimeProfileId:
|
||||
type: number
|
||||
nullable: true
|
||||
activeAnimeLanguageProfileId:
|
||||
type: number
|
||||
nullable: true
|
||||
activeAnimeProfileName:
|
||||
type: string
|
||||
example: 720p/1080p
|
||||
nullable: true
|
||||
activeAnimeDirectory:
|
||||
type: string
|
||||
nullable: true
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -689,9 +648,6 @@ components:
|
||||
- port
|
||||
- apiKey
|
||||
- useSsl
|
||||
- activeProfileId
|
||||
- activeProfileName
|
||||
- activeDirectory
|
||||
- is4k
|
||||
- enableSeasonFolders
|
||||
- isDefault
|
||||
@@ -2083,11 +2039,138 @@ components:
|
||||
type: string
|
||||
native_name:
|
||||
type: string
|
||||
OverrideRule:
|
||||
RoutingRule:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
readOnly: true
|
||||
name:
|
||||
type: string
|
||||
example: 'Anime Content'
|
||||
serviceType:
|
||||
type: string
|
||||
enum:
|
||||
- radarr
|
||||
- sonarr
|
||||
is4k:
|
||||
type: boolean
|
||||
priority:
|
||||
type: number
|
||||
users:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Comma-separated user IDs
|
||||
genres:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Comma-separated genre IDs
|
||||
languages:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Pipe-separated language codes (e.g. "ja|ko")
|
||||
keywords:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Comma-separated keyword IDs
|
||||
targetServiceId:
|
||||
type: number
|
||||
description: ID of the target Radarr/Sonarr instance
|
||||
activeProfileId:
|
||||
type: number
|
||||
nullable: true
|
||||
rootFolder:
|
||||
type: string
|
||||
nullable: true
|
||||
minimumAvailability:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- announced
|
||||
- inCinemas
|
||||
- released
|
||||
- null
|
||||
seriesType:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- standard
|
||||
- daily
|
||||
- anime
|
||||
- null
|
||||
tags:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Comma-separated tag IDs
|
||||
isFallback:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
RoutingRuleRequest:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- serviceType
|
||||
- targetServiceId
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
serviceType:
|
||||
type: string
|
||||
enum:
|
||||
- radarr
|
||||
- sonarr
|
||||
is4k:
|
||||
type: boolean
|
||||
priority:
|
||||
type: number
|
||||
users:
|
||||
type: string
|
||||
nullable: true
|
||||
genres:
|
||||
type: string
|
||||
nullable: true
|
||||
languages:
|
||||
type: string
|
||||
nullable: true
|
||||
keywords:
|
||||
type: string
|
||||
nullable: true
|
||||
targetServiceId:
|
||||
type: number
|
||||
activeProfileId:
|
||||
type: number
|
||||
nullable: true
|
||||
minimumAvailability:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- announced
|
||||
- inCinemas
|
||||
- released
|
||||
- null
|
||||
rootFolder:
|
||||
type: string
|
||||
nullable: true
|
||||
seriesType:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- standard
|
||||
- daily
|
||||
- anime
|
||||
- null
|
||||
tags:
|
||||
type: string
|
||||
nullable: true
|
||||
isFallback:
|
||||
type: boolean
|
||||
Certification:
|
||||
type: object
|
||||
properties:
|
||||
@@ -7807,41 +7890,72 @@ paths:
|
||||
message:
|
||||
type: string
|
||||
example: Unable to retrieve TV certifications.
|
||||
/overrideRule:
|
||||
/routingRule:
|
||||
get:
|
||||
summary: Get override rules
|
||||
description: Returns a list of all override rules with their conditions and settings
|
||||
summary: Get all routing rules
|
||||
description: Returns all routing rules ordered by priority (highest first).
|
||||
tags:
|
||||
- overriderule
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Override rules returned
|
||||
description: Routing rules returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OverrideRule'
|
||||
$ref: '#/components/schemas/RoutingRule'
|
||||
post:
|
||||
summary: Create override rule
|
||||
description: Creates a new Override Rule from the request body.
|
||||
summary: Create a new routing rule
|
||||
description: Creates a new routing rule. Priority is auto-assigned (highest existing + 10).
|
||||
tags:
|
||||
- overriderule
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoutingRuleRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were successfully created'
|
||||
'201':
|
||||
description: Routing rule created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OverrideRule'
|
||||
/overrideRule/{ruleId}:
|
||||
$ref: '#/components/schemas/RoutingRule'
|
||||
|
||||
/routingRule/{ruleId}:
|
||||
put:
|
||||
summary: Update override rule
|
||||
description: Updates an Override Rule from the request body.
|
||||
summary: Update a routing rule
|
||||
description: Updates an existing routing rule by ID.
|
||||
tags:
|
||||
- overriderule
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: ruleId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoutingRuleRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Routing rule updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoutingRule'
|
||||
'404':
|
||||
description: Routing rule not found
|
||||
delete:
|
||||
summary: Delete a routing rule
|
||||
description: Deletes a routing rule by ID.
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: ruleId
|
||||
@@ -7850,31 +7964,42 @@ paths:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were successfully updated'
|
||||
description: Routing rule deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoutingRule'
|
||||
'404':
|
||||
description: Routing rule not found
|
||||
|
||||
/routingRule/reorder:
|
||||
post:
|
||||
summary: Reorder routing rules
|
||||
description: Bulk update priorities by providing an ordered list of rule IDs (highest priority first).
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- ruleIds
|
||||
properties:
|
||||
ruleIds:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: Rules reordered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OverrideRule'
|
||||
delete:
|
||||
summary: Delete override rule by ID
|
||||
description: Deletes the override rule with the provided ruleId.
|
||||
tags:
|
||||
- overriderule
|
||||
parameters:
|
||||
- in: path
|
||||
name: ruleId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: Override rule successfully deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OverrideRule'
|
||||
$ref: '#/components/schemas/RoutingRule'
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
@@ -36,7 +36,6 @@ export class Blocklist implements BlocklistItem {
|
||||
@ManyToOne(() => User, (user) => user.id, {
|
||||
eager: true,
|
||||
})
|
||||
@Index()
|
||||
user?: User;
|
||||
|
||||
@OneToOne(() => Media, (media) => media.blocklist, {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
AfterLoad,
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -20,7 +19,6 @@ class Issue {
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
@Index()
|
||||
public issueType: IssueType;
|
||||
|
||||
@Column({ type: 'int', default: IssueStatus.OPEN })
|
||||
@@ -36,14 +34,12 @@ class Issue {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.createdIssues, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public createdBy: User;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
@@ -51,7 +47,6 @@ class Issue {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@Index()
|
||||
public modifiedBy?: User;
|
||||
|
||||
@OneToMany(() => IssueComment, (comment) => comment.issue, {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import Issue from './Issue';
|
||||
import { User } from './User';
|
||||
|
||||
@@ -18,13 +12,11 @@ class IssueComment {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public user: User;
|
||||
|
||||
@ManyToOne(() => Issue, (issue) => issue.comments, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public issue: Issue;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
|
||||
@@ -206,19 +206,6 @@ 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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
@@ -7,10 +6,10 @@ import {
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import OverrideRule from '@server/entity/OverrideRule';
|
||||
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { resolveRoute } from '@server/lib/routingResolver';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
@@ -202,133 +201,41 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply overrides if the user is not an admin or has the "advanced request" permission
|
||||
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
||||
// apply routing rules to determine request settings (server/profile/folder/tags)
|
||||
let tmdbKeywords: number[] = [];
|
||||
if ('keywords' in tmdbMedia.keywords) {
|
||||
tmdbKeywords = tmdbMedia.keywords.keywords.map((k: TmdbKeyword) => k.id);
|
||||
} else if ('results' in tmdbMedia.keywords) {
|
||||
tmdbKeywords = tmdbMedia.keywords.results.map((k: TmdbKeyword) => k.id);
|
||||
}
|
||||
|
||||
const isAdmin = user.hasPermission([Permission.MANAGE_REQUESTS], {
|
||||
type: 'or',
|
||||
});
|
||||
|
||||
let rootFolder = requestBody.rootFolder;
|
||||
let profileId = requestBody.profileId;
|
||||
let tags = requestBody.tags;
|
||||
const route = await resolveRoute({
|
||||
serviceType:
|
||||
requestBody.mediaType === MediaType.MOVIE ? 'radarr' : 'sonarr',
|
||||
is4k: requestBody.is4k ?? false,
|
||||
userId: requestUser.id,
|
||||
genres: tmdbMedia.genres.map((g) => g.id),
|
||||
language: tmdbMedia.original_language,
|
||||
keywords: tmdbKeywords,
|
||||
});
|
||||
|
||||
if (useOverrides) {
|
||||
const defaultRadarrId = requestBody.is4k
|
||||
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
|
||||
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
|
||||
const defaultSonarrId = requestBody.is4k
|
||||
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
||||
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
|
||||
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
const overrideRules = await overrideRuleRepository.find({
|
||||
where:
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? { radarrServiceId: defaultRadarrId }
|
||||
: { sonarrServiceId: defaultSonarrId },
|
||||
});
|
||||
|
||||
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||
const hasAnimeKeyword =
|
||||
'results' in tmdbMedia.keywords &&
|
||||
tmdbMedia.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
);
|
||||
|
||||
// Skip override rules if the media is an anime TV show as anime TV
|
||||
// is handled by default and override rules do not explicitly include
|
||||
// the anime keyword
|
||||
if (
|
||||
requestBody.mediaType === MediaType.TV &&
|
||||
hasAnimeKeyword &&
|
||||
(!rule.keywords ||
|
||||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.users &&
|
||||
!rule.users
|
||||
.split(',')
|
||||
.some((userId) => Number(userId) === requestUser.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.genre &&
|
||||
!rule.genre
|
||||
.split(',')
|
||||
.some((genreId) =>
|
||||
tmdbMedia.genres
|
||||
.map((genre) => genre.id)
|
||||
.includes(Number(genreId))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.language &&
|
||||
!rule.language
|
||||
.split('|')
|
||||
.some((languageId) => languageId === tmdbMedia.original_language)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.keywords &&
|
||||
!rule.keywords.split(',').some((keywordId) => {
|
||||
let keywordList: TmdbKeyword[] = [];
|
||||
|
||||
if ('keywords' in tmdbMedia.keywords) {
|
||||
keywordList = tmdbMedia.keywords.keywords;
|
||||
} else if ('results' in tmdbMedia.keywords) {
|
||||
keywordList = tmdbMedia.keywords.results;
|
||||
}
|
||||
|
||||
return keywordList
|
||||
.map((keyword: TmdbKeyword) => keyword.id)
|
||||
.includes(Number(keywordId));
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// hacky way to prioritize rules
|
||||
// TODO: make this better
|
||||
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
|
||||
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
|
||||
|
||||
const aSpecificity = keys.filter((key) => a[key] !== null).length;
|
||||
const bSpecificity = keys.filter((key) => b[key] !== null).length;
|
||||
|
||||
// Take the rule with the most specific condition first
|
||||
return bSpecificity - aSpecificity;
|
||||
})[0];
|
||||
|
||||
if (prioritizedRule) {
|
||||
if (prioritizedRule.rootFolder) {
|
||||
rootFolder = prioritizedRule.rootFolder;
|
||||
}
|
||||
if (prioritizedRule.profileId) {
|
||||
profileId = prioritizedRule.profileId;
|
||||
}
|
||||
if (prioritizedRule.tags) {
|
||||
tags = [
|
||||
...new Set([
|
||||
...(tags || []),
|
||||
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
logger.debug('Override rule applied.', {
|
||||
label: 'Media Request',
|
||||
overrides: prioritizedRule,
|
||||
});
|
||||
}
|
||||
}
|
||||
const serverId =
|
||||
isAdmin && requestBody.serverId != null
|
||||
? requestBody.serverId
|
||||
: route.serviceId;
|
||||
const profileId =
|
||||
isAdmin && requestBody.profileId != null
|
||||
? requestBody.profileId
|
||||
: route.profileId;
|
||||
const rootFolder =
|
||||
isAdmin && requestBody.rootFolder
|
||||
? requestBody.rootFolder
|
||||
: route.rootFolder;
|
||||
const tags = isAdmin && requestBody.tags ? requestBody.tags : route.tags;
|
||||
|
||||
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||
await mediaRepository.save(media);
|
||||
@@ -367,7 +274,7 @@ export class MediaRequest {
|
||||
? user
|
||||
: undefined,
|
||||
is4k: requestBody.is4k,
|
||||
serverId: requestBody.serverId,
|
||||
serverId: serverId,
|
||||
profileId: profileId,
|
||||
rootFolder: rootFolder,
|
||||
tags: tags,
|
||||
@@ -477,7 +384,7 @@ export class MediaRequest {
|
||||
? user
|
||||
: undefined,
|
||||
is4k: requestBody.is4k,
|
||||
serverId: requestBody.serverId,
|
||||
serverId: serverId,
|
||||
profileId: profileId,
|
||||
rootFolder: rootFolder,
|
||||
languageProfileId: requestBody.languageProfileId,
|
||||
@@ -521,14 +428,12 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
@@ -537,7 +442,6 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@Index()
|
||||
public modifiedBy?: User;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
69
server/entity/RoutingRule.ts
Normal file
69
server/entity/RoutingRule.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
class RoutingRule {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public name: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public serviceType: 'radarr' | 'sonarr';
|
||||
|
||||
@Column({ default: false })
|
||||
public is4k: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
public priority: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public users?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public genres?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public languages?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public keywords?: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public targetServiceId: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
public activeProfileId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public rootFolder?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public seriesType?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public tags?: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public minimumAvailability?: 'announced' | 'inCinemas' | 'released';
|
||||
|
||||
@Column({ default: false })
|
||||
public isFallback: boolean;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
public createdAt: Date;
|
||||
|
||||
@DbAwareColumn({
|
||||
type: 'datetime',
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
onUpdate: 'CURRENT_TIMESTAMP',
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<RoutingRule>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default RoutingRule;
|
||||
@@ -1,12 +1,6 @@
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import Media from './Media';
|
||||
|
||||
@Entity()
|
||||
@@ -26,7 +20,6 @@ class Season {
|
||||
@ManyToOne(() => Media, (media) => media.seasons, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Promise<Media>;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
|
||||
@Entity()
|
||||
@@ -23,7 +17,6 @@ class SeasonRequest {
|
||||
@ManyToOne(() => MediaRequest, (request) => request.seasons, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public request: MediaRequest;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
@@ -19,7 +18,6 @@ export class UserPushSubscription {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public user: User;
|
||||
|
||||
@Column()
|
||||
|
||||
@@ -47,14 +47,12 @@ export class Watchlist implements WatchlistItem {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Media;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
136
server/lib/routingResolver.ts
Normal file
136
server/lib/routingResolver.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import RoutingRule from '@server/entity/RoutingRule';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
export interface ResolvedRoute {
|
||||
serviceId: number;
|
||||
profileId?: number;
|
||||
rootFolder?: string;
|
||||
seriesType?: string;
|
||||
tags?: number[];
|
||||
minimumAvailability?: string;
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
serviceType: 'radarr' | 'sonarr';
|
||||
is4k: boolean;
|
||||
userId: number;
|
||||
genres: number[];
|
||||
language: string;
|
||||
keywords: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates routing rules top-to-bottom (by priority DESC).
|
||||
* First match wins. Falls back to the default instance if no rules match.
|
||||
*/
|
||||
export async function resolveRoute(
|
||||
params: RouteParams
|
||||
): Promise<ResolvedRoute> {
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
const settings = getSettings();
|
||||
|
||||
const rules = await routingRuleRepository.find({
|
||||
where: {
|
||||
serviceType: params.serviceType,
|
||||
is4k: params.is4k,
|
||||
},
|
||||
order: { priority: 'DESC' },
|
||||
});
|
||||
|
||||
for (const rule of rules) {
|
||||
if (matchesAllConditions(rule, params)) {
|
||||
logger.debug('Routing rule matched', {
|
||||
label: 'Routing',
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
targetServiceId: rule.targetServiceId,
|
||||
});
|
||||
|
||||
return {
|
||||
serviceId: rule.targetServiceId,
|
||||
profileId: rule.activeProfileId ?? undefined,
|
||||
rootFolder: rule.rootFolder ?? undefined,
|
||||
seriesType: rule.seriesType ?? undefined,
|
||||
tags: rule.tags ? rule.tags.split(',').map(Number) : undefined,
|
||||
minimumAvailability: rule.minimumAvailability ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'No routing rules matched (including fallback rules). Falling back to settings default.',
|
||||
{
|
||||
label: 'Routing',
|
||||
serviceType: params.serviceType,
|
||||
is4k: params.is4k,
|
||||
}
|
||||
);
|
||||
|
||||
const services =
|
||||
params.serviceType === 'radarr' ? settings.radarr : settings.sonarr;
|
||||
const defaultServiceIdx = services.findIndex(
|
||||
(s) => (params.is4k ? s.is4k : !s.is4k) && s.isDefault
|
||||
);
|
||||
|
||||
if (defaultServiceIdx === -1) {
|
||||
throw new Error(
|
||||
`No default ${params.serviceType} instance configured for ${
|
||||
params.is4k ? '4K' : 'non-4K'
|
||||
} content.`
|
||||
);
|
||||
}
|
||||
|
||||
return { serviceId: services[defaultServiceIdx].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule's conditions all match the request parameters.
|
||||
*
|
||||
* - No conditions (fallback) = always matches
|
||||
* - AND between condition types (all populated conditions must pass)
|
||||
* - OR within a condition type (any value can match)
|
||||
*/
|
||||
function matchesAllConditions(rule: RoutingRule, params: RouteParams): boolean {
|
||||
if (rule.isFallback) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasConditions =
|
||||
rule.users || rule.genres || rule.languages || rule.keywords;
|
||||
|
||||
if (!hasConditions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rule.users) {
|
||||
const ruleUserIds = rule.users.split(',').map(Number);
|
||||
if (!ruleUserIds.includes(params.userId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.genres) {
|
||||
const ruleGenreIds = rule.genres.split(',').map(Number);
|
||||
if (!ruleGenreIds.some((g) => params.genres.includes(g))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.languages) {
|
||||
const ruleLangs = rule.languages.split('|');
|
||||
if (!ruleLangs.includes(params.language)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.keywords) {
|
||||
const ruleKeywordIds = rule.keywords.split(',').map(Number);
|
||||
if (!ruleKeywordIds.some((k) => params.keywords.includes(k))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
187
server/lib/settings/migrations/0009_migrate_to_routing_rules.ts
Normal file
187
server/lib/settings/migrations/0009_migrate_to_routing_rules.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import OverrideRule from '@server/entity/OverrideRule';
|
||||
import RoutingRule from '@server/entity/RoutingRule';
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const ANIME_KEYWORD_ID = '210024';
|
||||
|
||||
const migrateToRoutingRules = async (settings: any): Promise<AllSettings> => {
|
||||
if (
|
||||
Array.isArray(settings.migrations) &&
|
||||
settings.migrations.includes('0009_migrate_to_routing_rules')
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const routingRuleRepo = getRepository(RoutingRule);
|
||||
let errorOccurred = false;
|
||||
|
||||
for (const radarr of settings.radarr || []) {
|
||||
if (!radarr.isDefault) continue;
|
||||
|
||||
try {
|
||||
await routingRuleRepo.save(
|
||||
new RoutingRule({
|
||||
name: `${radarr.name} Default Route`,
|
||||
serviceType: 'radarr',
|
||||
targetServiceId: radarr.id,
|
||||
is4k: radarr.is4k,
|
||||
isFallback: true,
|
||||
priority: 0,
|
||||
activeProfileId: radarr.activeProfileId || undefined,
|
||||
rootFolder: radarr.activeDirectory || undefined,
|
||||
minimumAvailability: radarr.minimumAvailability || 'released',
|
||||
tags:
|
||||
radarr.tags && radarr.tags.length > 0
|
||||
? radarr.tags.join(',')
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to create Radarr fallback routing rule for "${radarr.name}".`,
|
||||
error.message
|
||||
);
|
||||
errorOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const sonarr of settings.sonarr || []) {
|
||||
if (!sonarr.isDefault) continue;
|
||||
|
||||
try {
|
||||
await routingRuleRepo.save(
|
||||
new RoutingRule({
|
||||
name: `${sonarr.name} Default Route`,
|
||||
serviceType: 'sonarr',
|
||||
targetServiceId: sonarr.id,
|
||||
is4k: sonarr.is4k,
|
||||
isFallback: true,
|
||||
priority: 0,
|
||||
activeProfileId: sonarr.activeProfileId || undefined,
|
||||
rootFolder: sonarr.activeDirectory || undefined,
|
||||
seriesType: sonarr.seriesType || 'standard',
|
||||
tags:
|
||||
sonarr.tags && sonarr.tags.length > 0
|
||||
? sonarr.tags.join(',')
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to create Sonarr fallback routing rule for "${sonarr.name}".`,
|
||||
error.message
|
||||
);
|
||||
errorOccurred = true;
|
||||
}
|
||||
|
||||
const hasAnimeOverrides =
|
||||
sonarr.activeAnimeProfileId ||
|
||||
sonarr.activeAnimeDirectory ||
|
||||
(sonarr.animeTags && sonarr.animeTags.length > 0);
|
||||
|
||||
if (hasAnimeOverrides) {
|
||||
try {
|
||||
await routingRuleRepo.save(
|
||||
new RoutingRule({
|
||||
name: 'Anime',
|
||||
serviceType: 'sonarr',
|
||||
targetServiceId: sonarr.id,
|
||||
is4k: sonarr.is4k,
|
||||
isFallback: false,
|
||||
priority: 10,
|
||||
keywords: ANIME_KEYWORD_ID,
|
||||
activeProfileId:
|
||||
sonarr.activeAnimeProfileId ||
|
||||
sonarr.activeProfileId ||
|
||||
undefined,
|
||||
rootFolder:
|
||||
sonarr.activeAnimeDirectory ||
|
||||
sonarr.activeDirectory ||
|
||||
undefined,
|
||||
seriesType: sonarr.animeSeriesType || 'anime',
|
||||
tags:
|
||||
sonarr.animeTags && sonarr.animeTags.length > 0
|
||||
? sonarr.animeTags.join(',')
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to create Sonarr anime routing rule for "${sonarr.name}".`,
|
||||
error.message
|
||||
);
|
||||
errorOccurred = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let overrideRules: OverrideRule[] = [];
|
||||
try {
|
||||
const overrideRuleRepo = getRepository(OverrideRule);
|
||||
overrideRules = await overrideRuleRepo.find();
|
||||
} catch {
|
||||
// If the OverrideRule table doesn't exist or can't be queried, we can skip this step.
|
||||
}
|
||||
|
||||
let priority = 20;
|
||||
|
||||
for (const rule of overrideRules) {
|
||||
const isRadarr = rule.radarrServiceId != null;
|
||||
const serviceType: 'radarr' | 'sonarr' = isRadarr ? 'radarr' : 'sonarr';
|
||||
|
||||
const serviceIndex = isRadarr
|
||||
? rule.radarrServiceId!
|
||||
: rule.sonarrServiceId!;
|
||||
const services =
|
||||
serviceType === 'radarr' ? settings.radarr || [] : settings.sonarr || [];
|
||||
const targetService = services[serviceIndex];
|
||||
|
||||
if (!targetService) {
|
||||
console.error(
|
||||
`Skipping override rule #${rule.id}: ${serviceType} instance at index ${serviceIndex} not found in settings.`
|
||||
);
|
||||
errorOccurred = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await routingRuleRepo.save(
|
||||
new RoutingRule({
|
||||
name: `Migrated Rule #${rule.id}`,
|
||||
serviceType,
|
||||
targetServiceId: targetService.id,
|
||||
is4k: targetService.is4k,
|
||||
isFallback: false,
|
||||
priority,
|
||||
users: rule.users || undefined,
|
||||
genres: rule.genre || undefined,
|
||||
languages: rule.language || undefined,
|
||||
keywords: rule.keywords || undefined,
|
||||
activeProfileId: rule.profileId || undefined,
|
||||
rootFolder: rule.rootFolder || undefined,
|
||||
tags: rule.tags || undefined,
|
||||
})
|
||||
);
|
||||
|
||||
priority += 10;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to migrate override rule #${rule.id} to routing rule.`,
|
||||
error.message
|
||||
);
|
||||
errorOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorOccurred) {
|
||||
if (!Array.isArray(settings.migrations)) {
|
||||
settings.migrations = [];
|
||||
}
|
||||
settings.migrations.push('0009_migrate_to_routing_rules');
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
export default migrateToRoutingRules;
|
||||
@@ -45,7 +45,7 @@ class WatchlistSync {
|
||||
[
|
||||
Permission.AUTO_REQUEST,
|
||||
Permission.AUTO_REQUEST_MOVIE,
|
||||
Permission.AUTO_REQUEST_TV,
|
||||
Permission.AUTO_APPROVE_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
@@ -70,33 +70,13 @@ 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.BLOCKLISTED ||
|
||||
(m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
|
||||
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
|
||||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddForeignKeyIndexes1771259406751 implements MigrationInterface {
|
||||
name = 'AddForeignKeyIndexes1771259406751';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" DROP CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" DROP CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6bbafa28411e6046421991ea21"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_707b033c2d0653f75213614789" ON "issue_comment" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_180710fead1c94ca499c57a7d4" ON "issue_comment" ("issueId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_53d04c07c3f4f54eae372ed665" ON "issue" ("issueType") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_276e20d053f3cff1645803c95d" ON "issue" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_10b17b49d1ee77e7184216001e" ON "issue" ("createdById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_da88a1019c850d1a7b143ca02e" ON "issue" ("modifiedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6f14737e346d6b27d8e50d2157" ON "season_request" ("requestId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_087099b39600be695591da9a49" ON "season" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ADD CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ADD CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" DROP CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" DROP CONSTRAINT "FK_356721a49f145aa439c16e6b999"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_087099b39600be695591da9a49"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_356721a49f145aa439c16e6b99"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_09b94c932e84635c5461f3c0a9"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_03f7958328e311761b0de675fb"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_f4fc4efa14c3ba2b29c4525fa1"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6997bee94720f1ecb7f3113709"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a1aa713f41c99e9d10c48da75a"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6f14737e346d6b27d8e50d2157"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_da88a1019c850d1a7b143ca02e"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_10b17b49d1ee77e7184216001e"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_276e20d053f3cff1645803c95d"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_53d04c07c3f4f54eae372ed665"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_180710fead1c94ca499c57a7d4"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_707b033c2d0653f75213614789"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6641da8d831b93dfcb429f8b8b"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ae34e6b153a90672eb9dc4857d"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('blacklist_id_seq')`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT`
|
||||
);
|
||||
await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ADD CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" ADD CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddForeignKeyIndexes1771259394105 implements MigrationInterface {
|
||||
name = 'AddForeignKeyIndexes1771259394105';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blocklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blocklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_707b033c2d0653f75213614789" ON "issue_comment" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_180710fead1c94ca499c57a7d4" ON "issue_comment" ("issueId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_53d04c07c3f4f54eae372ed665" ON "issue" ("issueType") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_276e20d053f3cff1645803c95d" ON "issue" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_10b17b49d1ee77e7184216001e" ON "issue" ("createdById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_da88a1019c850d1a7b143ca02e" ON "issue" ("modifiedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6f14737e346d6b27d8e50d2157" ON "season_request" ("requestId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_087099b39600be695591da9a49" ON "season" ("mediaId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blocklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blocklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_087099b39600be695591da9a49"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6997bee94720f1ecb7f3113709"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_a1aa713f41c99e9d10c48da75a"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6f14737e346d6b27d8e50d2157"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_da88a1019c850d1a7b143ca02e"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_10b17b49d1ee77e7184216001e"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_276e20d053f3cff1645803c95d"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_53d04c07c3f4f54eae372ed665"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_180710fead1c94ca499c57a7d4"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_707b033c2d0653f75213614789"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6641da8d831b93dfcb429f8b8b"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_ae34e6b153a90672eb9dc4857d"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blocklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blocklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ import deprecatedRoute from '@server/middleware/deprecation';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import overrideRuleRoutes from '@server/routes/overrideRule';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
import routingRuleRoutes from '@server/routes/settings/routingRule';
|
||||
import watchlistRoutes from '@server/routes/watchlist';
|
||||
import {
|
||||
appDataPath,
|
||||
@@ -173,9 +173,9 @@ router.use('/issue', isAuthenticated(), issueRoutes);
|
||||
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use(
|
||||
'/overrideRule',
|
||||
'/routingRule',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
overrideRuleRoutes
|
||||
routingRuleRoutes
|
||||
);
|
||||
|
||||
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
||||
|
||||
@@ -174,12 +174,7 @@ mediaRoutes.delete(
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
|
||||
if (media.status === MediaStatus.BLOCKLISTED) {
|
||||
media.resetServiceData();
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
await mediaRepository.remove(media);
|
||||
}
|
||||
await mediaRepository.remove(media);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import OverrideRule from '@server/entity/OverrideRule';
|
||||
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
|
||||
const overrideRuleRoutes = Router();
|
||||
|
||||
overrideRuleRoutes.get(
|
||||
'/',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
|
||||
try {
|
||||
const rules = await overrideRuleRepository.find({});
|
||||
|
||||
return res.status(200).json(rules as OverrideRuleResultsResponse);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
overrideRuleRoutes.post<
|
||||
Record<string, string>,
|
||||
OverrideRule,
|
||||
{
|
||||
users?: string;
|
||||
genre?: string;
|
||||
language?: string;
|
||||
keywords?: string;
|
||||
profileId?: number;
|
||||
rootFolder?: string;
|
||||
tags?: string;
|
||||
radarrServiceId?: number;
|
||||
sonarrServiceId?: number;
|
||||
}
|
||||
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
|
||||
try {
|
||||
const rule = new OverrideRule({
|
||||
users: req.body.users,
|
||||
genre: req.body.genre,
|
||||
language: req.body.language,
|
||||
keywords: req.body.keywords,
|
||||
profileId: req.body.profileId,
|
||||
rootFolder: req.body.rootFolder,
|
||||
tags: req.body.tags,
|
||||
radarrServiceId: req.body.radarrServiceId,
|
||||
sonarrServiceId: req.body.sonarrServiceId,
|
||||
});
|
||||
|
||||
const newRule = await overrideRuleRepository.save(rule);
|
||||
|
||||
return res.status(200).json(newRule);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
overrideRuleRoutes.put<
|
||||
{ ruleId: string },
|
||||
OverrideRule,
|
||||
{
|
||||
users?: string;
|
||||
genre?: string;
|
||||
language?: string;
|
||||
keywords?: string;
|
||||
profileId?: number;
|
||||
rootFolder?: string;
|
||||
tags?: string;
|
||||
radarrServiceId?: number;
|
||||
sonarrServiceId?: number;
|
||||
}
|
||||
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
|
||||
try {
|
||||
const rule = await overrideRuleRepository.findOne({
|
||||
where: {
|
||||
id: Number(req.params.ruleId),
|
||||
},
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
return next({ status: 404, message: 'Override Rule not found.' });
|
||||
}
|
||||
|
||||
rule.users = req.body.users;
|
||||
rule.genre = req.body.genre;
|
||||
rule.language = req.body.language;
|
||||
rule.keywords = req.body.keywords;
|
||||
rule.profileId = req.body.profileId;
|
||||
rule.rootFolder = req.body.rootFolder;
|
||||
rule.tags = req.body.tags;
|
||||
rule.radarrServiceId = req.body.radarrServiceId;
|
||||
rule.sonarrServiceId = req.body.sonarrServiceId;
|
||||
|
||||
const newRule = await overrideRuleRepository.save(rule);
|
||||
|
||||
return res.status(200).json(newRule);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
|
||||
'/:ruleId',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
|
||||
try {
|
||||
const rule = await overrideRuleRepository.findOne({
|
||||
where: {
|
||||
id: Number(req.params.ruleId),
|
||||
},
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
return next({ status: 404, message: 'Override Rule not found.' });
|
||||
}
|
||||
|
||||
await overrideRuleRepository.remove(rule);
|
||||
|
||||
return res.status(200).json(rule);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default overrideRuleRoutes;
|
||||
@@ -1,4 +1,6 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import RoutingRule from '@server/entity/RoutingRule';
|
||||
import type { RadarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
@@ -136,6 +138,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
|
||||
|
||||
radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
|
||||
const radarrIndex = settings.radarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
@@ -145,6 +148,19 @@ radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||
return next({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const instanceId = Number(req.params.id);
|
||||
|
||||
const rulesToDelete = await routingRuleRepository.find({
|
||||
where: {
|
||||
serviceType: 'radarr',
|
||||
targetServiceId: instanceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (rulesToDelete.length > 0) {
|
||||
await routingRuleRepository.remove(rulesToDelete);
|
||||
}
|
||||
|
||||
const removed = settings.radarr.splice(radarrIndex, 1);
|
||||
await settings.save();
|
||||
|
||||
|
||||
370
server/routes/settings/routingRule.ts
Normal file
370
server/routes/settings/routingRule.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import RoutingRule from '@server/entity/RoutingRule';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import { In, Not } from 'typeorm';
|
||||
|
||||
const routingRuleRoutes = Router();
|
||||
|
||||
type ServiceType = 'radarr' | 'sonarr';
|
||||
|
||||
function resolveTargetService(
|
||||
serviceType: ServiceType,
|
||||
targetServiceId: number
|
||||
): RadarrSettings | SonarrSettings | undefined {
|
||||
const settings = getSettings();
|
||||
const services = serviceType === 'radarr' ? settings.radarr : settings.sonarr;
|
||||
return services.find((s) => s.id === targetServiceId);
|
||||
}
|
||||
|
||||
function hasAnyCondition(body: Record<string, unknown>): boolean {
|
||||
return !!(body.users || body.genres || body.languages || body.keywords);
|
||||
}
|
||||
|
||||
function parseActiveProfileId(
|
||||
raw: string | number | null | undefined
|
||||
): number | null {
|
||||
if (raw === '' || raw == null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
routingRuleRoutes.get(
|
||||
'/',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (_req, res, next) => {
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
try {
|
||||
const rules = await routingRuleRepository.find({
|
||||
order: { isFallback: 'ASC', priority: 'DESC' },
|
||||
});
|
||||
return res.status(200).json(rules);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
routingRuleRoutes.post(
|
||||
'/',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
try {
|
||||
const serviceType = req.body.serviceType as ServiceType;
|
||||
const targetServiceId = Number(req.body.targetServiceId);
|
||||
|
||||
if (!serviceType || !['radarr', 'sonarr'].includes(serviceType)) {
|
||||
return next({ status: 400, message: 'Invalid serviceType.' });
|
||||
}
|
||||
if (!Number.isFinite(targetServiceId) || targetServiceId < 0) {
|
||||
return next({ status: 400, message: 'Invalid targetServiceId.' });
|
||||
}
|
||||
|
||||
const target = resolveTargetService(serviceType, targetServiceId);
|
||||
if (!target) {
|
||||
return next({ status: 400, message: 'Target instance not found.' });
|
||||
}
|
||||
|
||||
const derivedIs4k = !!target.is4k;
|
||||
const isFallback = !!req.body.isFallback;
|
||||
|
||||
if (isFallback) {
|
||||
const existing = await routingRuleRepository.findOne({
|
||||
where: { serviceType, is4k: derivedIs4k, isFallback: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Fallback already exists for this serviceType/is4k.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!target.isDefault) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback rules must target a default instance.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFallback && !hasAnyCondition(req.body)) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Non-fallback rules must have at least one condition.',
|
||||
});
|
||||
}
|
||||
|
||||
const activeProfileId = parseActiveProfileId(req.body.activeProfileId);
|
||||
|
||||
if (isFallback) {
|
||||
if (!req.body.rootFolder) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback requires rootFolder.',
|
||||
});
|
||||
}
|
||||
|
||||
if (activeProfileId == null) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback requires activeProfileId.',
|
||||
});
|
||||
}
|
||||
|
||||
if (serviceType === 'radarr' && !req.body.minimumAvailability) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback requires minimumAvailability for radarr.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let priority = 0;
|
||||
if (!isFallback) {
|
||||
const highestRule = await routingRuleRepository.findOne({
|
||||
where: { serviceType, is4k: derivedIs4k, isFallback: false },
|
||||
order: { priority: 'DESC' },
|
||||
});
|
||||
priority = (highestRule?.priority ?? 0) + 10;
|
||||
}
|
||||
|
||||
const rule = new RoutingRule({
|
||||
name: req.body.name,
|
||||
serviceType,
|
||||
targetServiceId,
|
||||
is4k: derivedIs4k,
|
||||
isFallback,
|
||||
priority,
|
||||
users: isFallback ? null : req.body.users,
|
||||
genres: isFallback ? null : req.body.genres,
|
||||
languages: isFallback ? null : req.body.languages,
|
||||
keywords: isFallback ? null : req.body.keywords,
|
||||
activeProfileId: activeProfileId ?? undefined,
|
||||
rootFolder: req.body.rootFolder,
|
||||
seriesType: req.body.seriesType,
|
||||
tags: req.body.tags,
|
||||
minimumAvailability: req.body.minimumAvailability ?? null,
|
||||
});
|
||||
|
||||
const newRule = await routingRuleRepository.save(rule);
|
||||
return res.status(201).json(newRule);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
routingRuleRoutes.put<{ ruleId: string }>(
|
||||
'/:ruleId',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
try {
|
||||
const rule = await routingRuleRepository.findOne({
|
||||
where: { id: Number(req.params.ruleId) },
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
return next({ status: 404, message: 'Routing rule not found.' });
|
||||
}
|
||||
|
||||
const nextServiceType = (req.body.serviceType ??
|
||||
rule.serviceType) as ServiceType;
|
||||
const nextTargetServiceId = Number(
|
||||
req.body.targetServiceId ?? rule.targetServiceId
|
||||
);
|
||||
|
||||
const target = resolveTargetService(nextServiceType, nextTargetServiceId);
|
||||
if (!target) {
|
||||
return next({ status: 400, message: 'Target instance not found.' });
|
||||
}
|
||||
|
||||
const derivedIs4k = !!target.is4k;
|
||||
const derivedIsDefault = !!target.isDefault;
|
||||
const nextIsFallback = !!(req.body.isFallback ?? rule.isFallback);
|
||||
|
||||
if (nextIsFallback) {
|
||||
const existing = await routingRuleRepository.findOne({
|
||||
where: {
|
||||
serviceType: nextServiceType,
|
||||
is4k: derivedIs4k,
|
||||
isFallback: true,
|
||||
id: Not(rule.id),
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Fallback already exists for this serviceType/is4k.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mergedForConditionCheck = { ...rule, ...req.body };
|
||||
if (!nextIsFallback && !hasAnyCondition(mergedForConditionCheck)) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Non-fallback rules must have at least one condition.',
|
||||
});
|
||||
}
|
||||
|
||||
if (nextIsFallback && !derivedIsDefault) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback rules must target a default instance.',
|
||||
});
|
||||
}
|
||||
|
||||
const nextActiveProfileId = parseActiveProfileId(
|
||||
req.body.activeProfileId ?? rule.activeProfileId
|
||||
);
|
||||
|
||||
const nextRootFolder = (req.body.rootFolder ?? rule.rootFolder) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const nextMinimumAvailability =
|
||||
nextServiceType === 'radarr'
|
||||
? (req.body.minimumAvailability ?? rule.minimumAvailability)
|
||||
: null;
|
||||
|
||||
if (nextIsFallback) {
|
||||
if (!nextRootFolder) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback requires rootFolder.',
|
||||
});
|
||||
}
|
||||
if (nextActiveProfileId == null) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback requires activeProfileId.',
|
||||
});
|
||||
}
|
||||
if (nextServiceType === 'radarr' && !nextMinimumAvailability) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Fallback requires minimumAvailability for radarr.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nextIsFallback) {
|
||||
rule.priority = 0;
|
||||
} else if (typeof req.body.priority === 'number') {
|
||||
rule.priority = req.body.priority;
|
||||
} else {
|
||||
const groupChanged =
|
||||
rule.serviceType !== nextServiceType ||
|
||||
rule.is4k !== derivedIs4k ||
|
||||
rule.isFallback;
|
||||
|
||||
if (groupChanged) {
|
||||
const highestRule = await routingRuleRepository.findOne({
|
||||
where: {
|
||||
serviceType: nextServiceType,
|
||||
is4k: derivedIs4k,
|
||||
isFallback: false,
|
||||
},
|
||||
order: { priority: 'DESC' },
|
||||
});
|
||||
rule.priority = (highestRule?.priority ?? 0) + 10;
|
||||
}
|
||||
}
|
||||
|
||||
rule.name = req.body.name ?? rule.name;
|
||||
rule.serviceType = nextServiceType;
|
||||
rule.targetServiceId = nextTargetServiceId;
|
||||
rule.is4k = derivedIs4k;
|
||||
rule.isFallback = nextIsFallback;
|
||||
rule.users = nextIsFallback ? null : req.body.users;
|
||||
rule.genres = nextIsFallback ? null : req.body.genres;
|
||||
rule.languages = nextIsFallback ? null : req.body.languages;
|
||||
rule.keywords = nextIsFallback ? null : req.body.keywords;
|
||||
rule.activeProfileId = nextActiveProfileId ?? undefined;
|
||||
rule.rootFolder = nextRootFolder;
|
||||
rule.minimumAvailability = nextMinimumAvailability;
|
||||
rule.tags = req.body.tags;
|
||||
|
||||
const updatedRule = await routingRuleRepository.save(rule);
|
||||
return res.status(200).json(updatedRule);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
routingRuleRoutes.delete<{ ruleId: string }>(
|
||||
'/:ruleId',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
try {
|
||||
const rule = await routingRuleRepository.findOne({
|
||||
where: { id: Number(req.params.ruleId) },
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
return next({ status: 404, message: 'Routing rule not found.' });
|
||||
}
|
||||
|
||||
await routingRuleRepository.remove(rule);
|
||||
return res.status(200).json(rule);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
routingRuleRoutes.post(
|
||||
'/reorder',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
|
||||
try {
|
||||
const { ruleIds } = req.body as { ruleIds: number[] };
|
||||
|
||||
const MAX_RULE_IDS = 1000;
|
||||
|
||||
if (!Array.isArray(ruleIds)) {
|
||||
return next({ status: 400, message: 'ruleIds must be an array.' });
|
||||
}
|
||||
|
||||
if (ruleIds.length > MAX_RULE_IDS) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: `Too many ruleIds provided. Maximum allowed is ${MAX_RULE_IDS}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const rules = await routingRuleRepository.findBy({ id: In(ruleIds) });
|
||||
const fallbackIds = new Set(
|
||||
rules.filter((r) => r.isFallback).map((r) => r.id)
|
||||
);
|
||||
const orderedIds = ruleIds.filter((id) => !fallbackIds.has(id));
|
||||
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await routingRuleRepository.update(orderedIds[i], {
|
||||
priority: (orderedIds.length - i) * 10,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await routingRuleRepository.find({
|
||||
order: { isFallback: 'ASC', priority: 'DESC' },
|
||||
});
|
||||
|
||||
return res.status(200).json(refreshed);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default routingRuleRoutes;
|
||||
@@ -1,4 +1,6 @@
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import RoutingRule from '@server/entity/RoutingRule';
|
||||
import type { SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
@@ -108,6 +110,7 @@ sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
|
||||
|
||||
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const routingRuleRepository = getRepository(RoutingRule);
|
||||
|
||||
const sonarrIndex = settings.sonarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
@@ -119,6 +122,19 @@ sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
|
||||
.json({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const instanceId = Number(req.params.id);
|
||||
|
||||
const rulesToDelete = await routingRuleRepository.find({
|
||||
where: {
|
||||
serviceType: 'sonarr',
|
||||
targetServiceId: instanceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (rulesToDelete.length > 0) {
|
||||
await routingRuleRepository.remove(rulesToDelete);
|
||||
}
|
||||
|
||||
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
||||
await settings.save();
|
||||
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import {
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
UserSelector,
|
||||
} from '@app/components/Selector';
|
||||
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import type OverrideRule from '@server/entity/OverrideRule';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
|
||||
const messages = defineMessages('components.Settings.OverrideRuleModal', {
|
||||
createrule: 'New Override Rule',
|
||||
editrule: 'Edit Override Rule',
|
||||
create: 'Create rule',
|
||||
service: 'Service',
|
||||
serviceDescription: 'Apply this rule to the selected service.',
|
||||
selectService: 'Select service',
|
||||
conditions: 'Conditions',
|
||||
conditionsDescription:
|
||||
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
|
||||
settings: 'Settings',
|
||||
settingsDescription:
|
||||
'Specifies which settings will be changed when the above conditions are met.',
|
||||
users: 'Users',
|
||||
genres: 'Genres',
|
||||
languages: 'Languages',
|
||||
keywords: 'Keywords',
|
||||
rootfolder: 'Root Folder',
|
||||
selectRootFolder: 'Select root folder',
|
||||
qualityprofile: 'Quality Profile',
|
||||
selectQualityProfile: 'Select quality profile',
|
||||
tags: 'Tags',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
ruleCreated: 'Override rule created successfully!',
|
||||
ruleUpdated: 'Override rule updated successfully!',
|
||||
});
|
||||
|
||||
type OptionType = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
interface OverrideRuleModalProps {
|
||||
rule: OverrideRule | null;
|
||||
onClose: () => void;
|
||||
radarrServices: RadarrSettings[];
|
||||
sonarrServices: SonarrSettings[];
|
||||
}
|
||||
|
||||
const OverrideRuleModal = ({
|
||||
onClose,
|
||||
rule,
|
||||
radarrServices,
|
||||
sonarrServices,
|
||||
}: OverrideRuleModalProps) => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { currentSettings } = useSettings();
|
||||
const [isValidated, setIsValidated] = useState(rule ? true : false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const getServiceInfos = useCallback(
|
||||
async (
|
||||
{
|
||||
hostname,
|
||||
port,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
useSsl = false,
|
||||
}: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
useSsl?: boolean;
|
||||
},
|
||||
type: 'radarr' | 'sonarr'
|
||||
) => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const response = await axios.post<DVRTestResponse>(
|
||||
`/api/v1/settings/${type}/test`,
|
||||
{
|
||||
hostname,
|
||||
apiKey,
|
||||
port: Number(port),
|
||||
baseUrl,
|
||||
useSsl,
|
||||
}
|
||||
);
|
||||
|
||||
setIsValidated(true);
|
||||
setTestResponse(response.data);
|
||||
} catch (e) {
|
||||
setIsValidated(false);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
rule?.radarrServiceId !== null &&
|
||||
rule?.radarrServiceId !== undefined &&
|
||||
radarrServices[rule?.radarrServiceId]
|
||||
) {
|
||||
getServiceInfos(radarrServices[rule?.radarrServiceId], 'radarr');
|
||||
}
|
||||
if (
|
||||
rule?.sonarrServiceId !== null &&
|
||||
rule?.sonarrServiceId !== undefined &&
|
||||
sonarrServices[rule?.sonarrServiceId]
|
||||
) {
|
||||
getServiceInfos(sonarrServices[rule?.sonarrServiceId], 'sonarr');
|
||||
}
|
||||
}, [
|
||||
getServiceInfos,
|
||||
radarrServices,
|
||||
rule?.radarrServiceId,
|
||||
rule?.sonarrServiceId,
|
||||
sonarrServices,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
radarrServiceId: rule?.radarrServiceId,
|
||||
sonarrServiceId: rule?.sonarrServiceId,
|
||||
users: rule?.users,
|
||||
genre: rule?.genre,
|
||||
language: rule?.language,
|
||||
keywords: rule?.keywords,
|
||||
profileId: rule?.profileId,
|
||||
rootFolder: rule?.rootFolder,
|
||||
tags: rule?.tags,
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const submission = {
|
||||
users: values.users || null,
|
||||
genre: values.genre || null,
|
||||
language: values.language || null,
|
||||
keywords: values.keywords || null,
|
||||
profileId: Number(values.profileId) || null,
|
||||
rootFolder: values.rootFolder || null,
|
||||
tags: values.tags || null,
|
||||
radarrServiceId: values.radarrServiceId,
|
||||
sonarrServiceId: values.sonarrServiceId,
|
||||
};
|
||||
if (!rule) {
|
||||
await axios.post('/api/v1/overrideRule', submission);
|
||||
addToast(intl.formatMessage(messages.ruleCreated), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} else {
|
||||
await axios.put(`/api/v1/overrideRule/${rule.id}`, submission);
|
||||
addToast(intl.formatMessage(messages.ruleUpdated), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// set error here
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
onCancel={onClose}
|
||||
okButtonType="primary"
|
||||
okText={
|
||||
isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: rule
|
||||
? intl.formatMessage(globalMessages.save)
|
||||
: intl.formatMessage(messages.create)
|
||||
}
|
||||
okDisabled={
|
||||
isSubmitting ||
|
||||
!isValid ||
|
||||
(!values.users &&
|
||||
!values.genre &&
|
||||
!values.language &&
|
||||
!values.keywords) ||
|
||||
(!values.rootFolder && !values.profileId && !values.tags)
|
||||
}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!rule
|
||||
? intl.formatMessage(messages.createrule)
|
||||
: intl.formatMessage(messages.editrule)
|
||||
}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.service)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.serviceDescription)}
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label htmlFor="service" className="text-label">
|
||||
{intl.formatMessage(messages.service)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<select
|
||||
id="service"
|
||||
name="service"
|
||||
defaultValue={
|
||||
values.radarrServiceId !== null
|
||||
? `radarr-${values.radarrServiceId}`
|
||||
: `sonarr-${values.sonarrServiceId}`
|
||||
}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value.split('-')[1]);
|
||||
if (e.target.value.startsWith('radarr-')) {
|
||||
setFieldValue('radarrServiceId', id);
|
||||
setFieldValue('sonarrServiceId', null);
|
||||
if (radarrServices[id]) {
|
||||
getServiceInfos(radarrServices[id], 'radarr');
|
||||
}
|
||||
} else if (e.target.value.startsWith('sonarr-')) {
|
||||
setFieldValue('radarrServiceId', null);
|
||||
setFieldValue('sonarrServiceId', id);
|
||||
if (sonarrServices[id]) {
|
||||
getServiceInfos(sonarrServices[id], 'sonarr');
|
||||
}
|
||||
} else {
|
||||
setFieldValue('radarrServiceId', null);
|
||||
setFieldValue('sonarrServiceId', null);
|
||||
setIsValidated(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectService)}
|
||||
</option>
|
||||
{radarrServices.map((radarr) => (
|
||||
<option
|
||||
key={`radarr-${radarr.id}`}
|
||||
value={`radarr-${radarr.id}`}
|
||||
>
|
||||
{radarr.name}
|
||||
</option>
|
||||
))}
|
||||
{sonarrServices.map((sonarr) => (
|
||||
<option
|
||||
key={`sonarr-${sonarr.id}`}
|
||||
value={`sonarr-${sonarr.id}`}
|
||||
>
|
||||
{sonarr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{errors.rootFolder &&
|
||||
touched.rootFolder &&
|
||||
typeof errors.rootFolder === 'string' && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.conditionsDescription)}
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label htmlFor="users" className="text-label">
|
||||
{intl.formatMessage(messages.users)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<UserSelector
|
||||
defaultValue={values.users}
|
||||
isDisabled={!isValidated || isTesting}
|
||||
isMulti
|
||||
onChange={(users) => {
|
||||
setFieldValue(
|
||||
'users',
|
||||
users?.map((v) => v.value).join(',')
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{errors.users &&
|
||||
touched.users &&
|
||||
typeof errors.users === 'string' && (
|
||||
<div className="error">{errors.users}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="genre" className="text-label">
|
||||
{intl.formatMessage(messages.genres)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<GenreSelector
|
||||
type={
|
||||
values.radarrServiceId != null
|
||||
? 'movie'
|
||||
: values.sonarrServiceId != null
|
||||
? 'tv'
|
||||
: 'tv'
|
||||
}
|
||||
defaultValue={values.genre}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
onChange={(genres) => {
|
||||
setFieldValue(
|
||||
'genre',
|
||||
genres?.map((v) => v.value).join(',')
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{errors.genre &&
|
||||
touched.genre &&
|
||||
typeof errors.genre === 'string' && (
|
||||
<div className="error">{errors.genre}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="language" className="text-label">
|
||||
{intl.formatMessage(messages.languages)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<LanguageSelector
|
||||
value={values.language}
|
||||
serverValue={currentSettings.originalLanguage}
|
||||
setFieldValue={(_key, value) => {
|
||||
setFieldValue('language', value);
|
||||
}}
|
||||
isDisabled={!isValidated || isTesting}
|
||||
/>
|
||||
</div>
|
||||
{errors.language &&
|
||||
touched.language &&
|
||||
typeof errors.language === 'string' && (
|
||||
<div className="error">{errors.language}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="keywords" className="text-label">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<KeywordSelector
|
||||
defaultValue={values.keywords}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'keywords',
|
||||
value?.map((v) => v.value).join(',')
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{errors.keywords &&
|
||||
touched.keywords &&
|
||||
typeof errors.keywords === 'string' && (
|
||||
<div className="error">{errors.keywords}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.settings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.settingsDescription)}
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label htmlFor="rootFolderRule" className="text-label">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolderRule"
|
||||
name="rootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
<option
|
||||
key={`loaded-profile-${folder.id}`}
|
||||
value={folder.path}
|
||||
>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.rootFolder &&
|
||||
touched.rootFolder &&
|
||||
typeof errors.rootFolder === 'string' && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="profileIdRule" className="text-label">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="profileIdRule"
|
||||
name="profileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
<option
|
||||
key={`loaded-profile-${profile.id}`}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.profileId &&
|
||||
touched.profileId &&
|
||||
typeof errors.profileId === 'string' && (
|
||||
<div className="error">{errors.profileId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Select<OptionType, true>
|
||||
options={testResponse.tags.map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
placeholder={intl.formatMessage(messages.selecttags)}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
(values?.tags
|
||||
?.split(',')
|
||||
.map((tagId) => {
|
||||
const foundTag = testResponse.tags.find(
|
||||
(tag) => tag.id === Number(tagId)
|
||||
);
|
||||
|
||||
if (!foundTag) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: foundTag.id,
|
||||
label: foundTag.label,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(option) => option !== undefined
|
||||
) as OptionType[]) || []
|
||||
}
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'tags',
|
||||
value.map((option) => option.value).join(',')
|
||||
);
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.notagoptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverrideRuleModal;
|
||||
@@ -1,309 +0,0 @@
|
||||
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||
import type OverrideRule from '@server/entity/OverrideRule';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type {
|
||||
DVRSettings,
|
||||
Language,
|
||||
RadarrSettings,
|
||||
SonarrSettings,
|
||||
} from '@server/lib/settings';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages('components.Settings.OverrideRuleTile', {
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
tags: 'Tags',
|
||||
users: 'Users',
|
||||
genre: 'Genre',
|
||||
language: 'Language',
|
||||
keywords: 'Keywords',
|
||||
conditions: 'Conditions',
|
||||
settings: 'Settings',
|
||||
});
|
||||
|
||||
interface OverrideRuleTilesProps {
|
||||
rules: OverrideRule[];
|
||||
setOverrideRuleModal: ({
|
||||
open,
|
||||
rule,
|
||||
}: {
|
||||
open: boolean;
|
||||
rule: OverrideRule | null;
|
||||
}) => void;
|
||||
revalidate: () => void;
|
||||
radarrServices: RadarrSettings[];
|
||||
sonarrServices: SonarrSettings[];
|
||||
}
|
||||
|
||||
const OverrideRuleTiles = ({
|
||||
rules,
|
||||
setOverrideRuleModal,
|
||||
revalidate,
|
||||
radarrServices,
|
||||
sonarrServices,
|
||||
}: OverrideRuleTilesProps) => {
|
||||
const intl = useIntl();
|
||||
const [users, setUsers] = useState<User[] | null>(null);
|
||||
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
|
||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
|
||||
const [testResponses, setTestResponses] = useState<
|
||||
(DVRTestResponse & { type: string; id: number })[]
|
||||
>([]);
|
||||
|
||||
const getServiceInfos = useCallback(async () => {
|
||||
const results: (DVRTestResponse & { type: string; id: number })[] = [];
|
||||
const services: DVRSettings[] = [...radarrServices, ...sonarrServices];
|
||||
for (const service of services) {
|
||||
const { hostname, port, apiKey, baseUrl, useSsl = false } = service;
|
||||
try {
|
||||
const response = await axios.post<DVRTestResponse>(
|
||||
`/api/v1/settings/${
|
||||
radarrServices.includes(service as RadarrSettings)
|
||||
? 'radarr'
|
||||
: 'sonarr'
|
||||
}/test`,
|
||||
{
|
||||
hostname,
|
||||
apiKey,
|
||||
port: Number(port),
|
||||
baseUrl,
|
||||
useSsl,
|
||||
}
|
||||
);
|
||||
results.push({
|
||||
type: radarrServices.includes(service as RadarrSettings)
|
||||
? 'radarr'
|
||||
: 'sonarr',
|
||||
id: service.id,
|
||||
...response.data,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
type: radarrServices.includes(service as RadarrSettings)
|
||||
? 'radarr'
|
||||
: 'sonarr',
|
||||
id: service.id,
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
setTestResponses(results);
|
||||
}, [radarrServices, sonarrServices]);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceInfos();
|
||||
}, [getServiceInfos]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const keywords = await Promise.all(
|
||||
rules
|
||||
.map((rule) => rule.keywords?.split(','))
|
||||
.flat()
|
||||
.filter((keywordId) => keywordId)
|
||||
.map(async (keywordId) => {
|
||||
const response = await axios.get<Keyword | null>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return response.data;
|
||||
})
|
||||
);
|
||||
const validKeywords: Keyword[] = keywords.filter(
|
||||
(keyword): keyword is Keyword => keyword !== null
|
||||
);
|
||||
setKeywords(validKeywords);
|
||||
const allUsersFromRules = rules
|
||||
.map((rule) => rule.users)
|
||||
.filter((users) => users)
|
||||
.join(',');
|
||||
if (allUsersFromRules) {
|
||||
const response = await axios.get(
|
||||
`/api/v1/user?includeIds=${encodeURIComponent(allUsersFromRules)}`
|
||||
);
|
||||
const users: User[] = response.data.results;
|
||||
setUsers(users);
|
||||
}
|
||||
})();
|
||||
}, [rules, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rules.map((rule) => (
|
||||
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
|
||||
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
|
||||
<div className="flex-1 truncate">
|
||||
<span className="text-lg">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</span>
|
||||
{rule.users && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.users)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.users.split(',').map((userId) => {
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
users?.find((user) => user.id === Number(userId))
|
||||
?.displayName
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.genre && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.genre)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.genre.split(',').map((genreId) => (
|
||||
<span>
|
||||
{genres?.find((g) => g.id === Number(genreId))?.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.language && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.language)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.language
|
||||
.split('|')
|
||||
.filter((languageId) => languageId !== 'server')
|
||||
.map((languageId) => {
|
||||
const language = languages?.find(
|
||||
(language) => language.iso_639_1 === languageId
|
||||
);
|
||||
if (!language) return null;
|
||||
const languageName =
|
||||
intl.formatDisplayName(language.iso_639_1, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ?? language.english_name;
|
||||
return <span>{languageName}</span>;
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.keywords && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.keywords.split(',').map((keywordId) => {
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
keywords?.find(
|
||||
(keyword) => keyword.id === Number(keywordId)
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
<span className="text-lg">
|
||||
{intl.formatMessage(messages.settings)}
|
||||
</span>
|
||||
{rule.profileId && (
|
||||
<p className="runcate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</span>
|
||||
{testResponses
|
||||
.find(
|
||||
(r) =>
|
||||
(r.id === rule.radarrServiceId &&
|
||||
r.type === 'radarr') ||
|
||||
(r.id === rule.sonarrServiceId && r.type === 'sonarr')
|
||||
)
|
||||
?.profiles.find((profile) => rule.profileId === profile.id)
|
||||
?.name || rule.profileId}
|
||||
</p>
|
||||
)}
|
||||
{rule.rootFolder && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</span>
|
||||
{rule.rootFolder}
|
||||
</p>
|
||||
)}
|
||||
{rule.tags && rule.tags.length > 0 && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.tags.split(',').map((tag) => (
|
||||
<span>
|
||||
{testResponses
|
||||
.find(
|
||||
(r) =>
|
||||
(r.id === rule.radarrServiceId &&
|
||||
r.type === 'radarr') ||
|
||||
(r.id === rule.sonarrServiceId &&
|
||||
r.type === 'sonarr')
|
||||
)
|
||||
?.tags?.find((t) => t.id === Number(tag))?.label ||
|
||||
tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-500">
|
||||
<div className="-mt-px flex">
|
||||
<div className="flex w-0 flex-1 border-r border-gray-500">
|
||||
<button
|
||||
onClick={() => setOverrideRuleModal({ open: true, rule })}
|
||||
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||
>
|
||||
<PencilIcon className="mr-2 h-5 w-5" />
|
||||
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="-ml-px flex w-0 flex-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await axios.delete(`/api/v1/overrideRule/${rule.id}`);
|
||||
revalidate();
|
||||
}}
|
||||
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||
>
|
||||
<TrashIcon className="mr-2 h-5 w-5" />
|
||||
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverrideRuleTiles;
|
||||
@@ -10,15 +10,9 @@ import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type OptionType = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const messages = defineMessages('components.Settings.RadarrModal', {
|
||||
createradarr: 'Add New Radarr Server',
|
||||
create4kradarr: 'Add New 4K Radarr Server',
|
||||
@@ -28,10 +22,6 @@ const messages = defineMessages('components.Settings.RadarrModal', {
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
validationProfileRequired: 'You must select a quality profile',
|
||||
validationMinimumAvailabilityRequired:
|
||||
'You must select a minimum availability',
|
||||
toastRadarrTestSuccess: 'Radarr connection established successfully!',
|
||||
toastRadarrTestFailure: 'Failed to connect to Radarr.',
|
||||
add: 'Add Server',
|
||||
@@ -43,22 +33,9 @@ const messages = defineMessages('components.Settings.RadarrModal', {
|
||||
ssl: 'Use SSL',
|
||||
apiKey: 'API Key',
|
||||
baseUrl: 'URL Base',
|
||||
server4k: '4K Server',
|
||||
syncEnabled: 'Enable Scan',
|
||||
externalUrl: 'External URL',
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
minimumAvailability: 'Minimum Availability',
|
||||
server4k: '4K Server',
|
||||
selectQualityProfile: 'Select quality profile',
|
||||
selectRootFolder: 'Select root folder',
|
||||
selectMinimumAvailability: 'Select minimum availability',
|
||||
loadingprofiles: 'Loading quality profiles…',
|
||||
testFirstQualityProfiles: 'Test connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test connection to load root folders',
|
||||
loadingTags: 'Loading tags…',
|
||||
testFirstTags: 'Test connection to load tags',
|
||||
tags: 'Tags',
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
tagRequests: 'Tag Requests',
|
||||
tagRequestsInfo:
|
||||
@@ -67,17 +44,12 @@ const messages = defineMessages('components.Settings.RadarrModal', {
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
|
||||
validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
announced: 'Announced',
|
||||
inCinemas: 'In Cinemas',
|
||||
released: 'Released',
|
||||
});
|
||||
|
||||
interface RadarrModalProps {
|
||||
radarr: RadarrSettings | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSave: (savedInstance: RadarrSettings) => Promise<void>;
|
||||
}
|
||||
|
||||
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
@@ -105,15 +77,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
apiKey: Yup.string().required(
|
||||
intl.formatMessage(messages.validationApiKeyRequired)
|
||||
),
|
||||
rootFolder: Yup.string().required(
|
||||
intl.formatMessage(messages.validationRootFolderRequired)
|
||||
),
|
||||
activeProfileId: Yup.string().required(
|
||||
intl.formatMessage(messages.validationProfileRequired)
|
||||
),
|
||||
minimumAvailability: Yup.string().required(
|
||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||
),
|
||||
externalUrl: Yup.string()
|
||||
.test(
|
||||
'valid-url',
|
||||
@@ -221,10 +184,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
ssl: radarr?.useSsl ?? false,
|
||||
apiKey: radarr?.apiKey,
|
||||
baseUrl: radarr?.baseUrl,
|
||||
activeProfileId: radarr?.activeProfileId,
|
||||
rootFolder: radarr?.activeDirectory,
|
||||
minimumAvailability: radarr?.minimumAvailability ?? 'released',
|
||||
tags: radarr?.tags ?? [],
|
||||
isDefault: radarr?.isDefault ?? false,
|
||||
is4k: radarr?.is4k ?? false,
|
||||
externalUrl: radarr?.externalUrl,
|
||||
@@ -235,10 +194,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
validationSchema={RadarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const profileName = testResponse.profiles.find(
|
||||
(profile) => profile.id === Number(values.activeProfileId)
|
||||
)?.name;
|
||||
|
||||
const submission = {
|
||||
name: values.name,
|
||||
hostname: values.hostname,
|
||||
@@ -246,30 +201,32 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
apiKey: values.apiKey,
|
||||
useSsl: values.ssl,
|
||||
baseUrl: values.baseUrl,
|
||||
activeProfileId: Number(values.activeProfileId),
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
is4k: values.is4k,
|
||||
minimumAvailability: values.minimumAvailability,
|
||||
tags: values.tags,
|
||||
isDefault: values.isDefault,
|
||||
is4k: values.is4k,
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: !values.enableSearch,
|
||||
tagRequests: values.tagRequests,
|
||||
};
|
||||
|
||||
let savedInstance: RadarrSettings;
|
||||
if (!radarr) {
|
||||
await axios.post('/api/v1/settings/radarr', submission);
|
||||
const response = await axios.post<RadarrSettings>(
|
||||
'/api/v1/settings/radarr',
|
||||
submission
|
||||
);
|
||||
savedInstance = response.data;
|
||||
} else {
|
||||
await axios.put(
|
||||
const response = await axios.put<RadarrSettings>(
|
||||
`/api/v1/settings/radarr/${radarr.id}`,
|
||||
submission
|
||||
);
|
||||
savedInstance = response.data;
|
||||
}
|
||||
|
||||
onSave();
|
||||
await onSave(savedInstance);
|
||||
} catch (e) {
|
||||
// set error here
|
||||
// TODO: handle error
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -501,176 +458,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeProfileId" className="text-label">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectQualityProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
<option
|
||||
key={`loaded-profile-${profile.id}`}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeProfileId &&
|
||||
touched.activeProfileId &&
|
||||
typeof errors.activeProfileId === 'string' && (
|
||||
<div className="error">{errors.activeProfileId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="rootFolder" className="text-label">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstRootFolders
|
||||
)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
<option
|
||||
key={`loaded-profile-${folder.id}`}
|
||||
value={folder.path}
|
||||
>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.rootFolder &&
|
||||
touched.rootFolder &&
|
||||
typeof errors.rootFolder === 'string' && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="minimumAvailability" className="text-label">
|
||||
{intl.formatMessage(messages.minimumAvailability)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="minimumAvailability"
|
||||
name="minimumAvailability"
|
||||
>
|
||||
<option value="announced">
|
||||
{intl.formatMessage(messages.announced)}
|
||||
</option>
|
||||
<option value="inCinemas">
|
||||
{intl.formatMessage(messages.inCinemas)}
|
||||
</option>
|
||||
<option value="released">
|
||||
{intl.formatMessage(messages.released)}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.minimumAvailability &&
|
||||
touched.minimumAvailability && (
|
||||
<div className="error">
|
||||
{errors.minimumAvailability}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Select<OptionType, true>
|
||||
options={
|
||||
isValidated
|
||||
? testResponse.tags.map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
placeholder={
|
||||
!isValidated
|
||||
? intl.formatMessage(messages.testFirstTags)
|
||||
: isTesting
|
||||
? intl.formatMessage(messages.loadingTags)
|
||||
: intl.formatMessage(messages.selecttags)
|
||||
}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
values.tags
|
||||
.map((tagId) => {
|
||||
const foundTag = testResponse.tags.find(
|
||||
(tag) => tag.id === tagId
|
||||
);
|
||||
|
||||
if (!foundTag) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: foundTag.id,
|
||||
label: foundTag.label,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(option) => option !== undefined
|
||||
) as OptionType[]
|
||||
}
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'tags',
|
||||
value.map((option) => option.value)
|
||||
);
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.notagoptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="externalUrl" className="text-label">
|
||||
{intl.formatMessage(messages.externalUrl)}
|
||||
|
||||
599
src/components/Settings/RoutingRule/RoutingRuleList.tsx
Normal file
599
src/components/Settings/RoutingRule/RoutingRuleList.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import RoutingRuleRow from '@app/components/Settings/RoutingRule/RoutingRuleRow';
|
||||
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
|
||||
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type {
|
||||
Language,
|
||||
RadarrSettings,
|
||||
SonarrSettings,
|
||||
} from '@server/lib/settings';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type FilterType = 'all' | 'sonarr' | 'radarr';
|
||||
type ServiceType = 'radarr' | 'sonarr';
|
||||
|
||||
const messages = defineMessages('components.Settings.RoutingRuleList', {
|
||||
routingRules: 'Routing Rules',
|
||||
routingRulesDescription:
|
||||
'Rules are evaluated top-to-bottom. The first matching rule determines where the request is sent. Drag to reorder priority.',
|
||||
routingRulesConditionLogic:
|
||||
'Conditions use AND logic between fields (all must match) and OR logic within a field (any value can match).',
|
||||
addRule: 'Add Rule',
|
||||
all: 'All',
|
||||
sonarr: 'Sonarr',
|
||||
radarr: 'Radarr',
|
||||
howRoutingWorks: 'How routing works:',
|
||||
routingExplainer:
|
||||
'When a request comes in, rules are checked from top to bottom. The first rule whose conditions all match will determine which instance and settings are used. Fallback rules (no conditions) catch everything that did not match above.',
|
||||
noFallbackWarning:
|
||||
'No fallback rule configured for {serviceType}. Requests that do not match any rule will fail.',
|
||||
deleteConfirm: 'Are you sure you want to delete this routing rule?',
|
||||
deleteRule: 'Delete Routing Rule',
|
||||
animeRuleSuggestion:
|
||||
'Want anime to use different settings? Add an anime routing rule.',
|
||||
addAnimeRule: 'Add Anime Rule',
|
||||
});
|
||||
|
||||
interface RoutingRuleListProps {
|
||||
rules: RoutingRule[];
|
||||
radarrServices: RadarrSettings[];
|
||||
sonarrServices: SonarrSettings[];
|
||||
onAddRule: (prefillData?: Partial<RoutingRule>) => void;
|
||||
onEditRule: (rule: RoutingRule) => void;
|
||||
revalidate: () => void;
|
||||
}
|
||||
|
||||
const isFallbackRule = (r: RoutingRule) => !!r.isFallback;
|
||||
|
||||
const hasFallback = (
|
||||
rules: RoutingRule[],
|
||||
serviceType: ServiceType,
|
||||
is4k: boolean
|
||||
) =>
|
||||
rules.some(
|
||||
(r) => r.serviceType === serviceType && !!r.isFallback && !!r.is4k === is4k
|
||||
);
|
||||
|
||||
function getDefaultInstance(
|
||||
serviceType: ServiceType,
|
||||
is4k: boolean,
|
||||
radarrServices: RadarrSettings[],
|
||||
sonarrServices: SonarrSettings[]
|
||||
) {
|
||||
const services = serviceType === 'radarr' ? radarrServices : sonarrServices;
|
||||
return services.find((s) => !!s.isDefault && !!s.is4k === is4k);
|
||||
}
|
||||
|
||||
const RoutingRuleList = ({
|
||||
rules,
|
||||
radarrServices,
|
||||
sonarrServices,
|
||||
onAddRule,
|
||||
onEditRule,
|
||||
revalidate,
|
||||
}: RoutingRuleListProps) => {
|
||||
const intl = useIntl();
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
open: boolean;
|
||||
rule: RoutingRule | null;
|
||||
}>({ open: false, rule: null });
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [keywordsData, setKeywordsData] = useState<Keyword[]>([]);
|
||||
const [testResponses, setTestResponses] = useState<
|
||||
(DVRTestResponse & { type: string; id: number })[]
|
||||
>([]);
|
||||
const [localOrder, setLocalOrder] = useState<RoutingRule[] | null>(null);
|
||||
|
||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
|
||||
|
||||
const radarrDefaultNon4k = useMemo(
|
||||
() => getDefaultInstance('radarr', false, radarrServices, sonarrServices),
|
||||
[radarrServices, sonarrServices]
|
||||
);
|
||||
const radarrDefault4k = useMemo(
|
||||
() => getDefaultInstance('radarr', true, radarrServices, sonarrServices),
|
||||
[radarrServices, sonarrServices]
|
||||
);
|
||||
const sonarrDefaultNon4k = useMemo(
|
||||
() => getDefaultInstance('sonarr', false, radarrServices, sonarrServices),
|
||||
[radarrServices, sonarrServices]
|
||||
);
|
||||
const sonarrDefault4k = useMemo(
|
||||
() => getDefaultInstance('sonarr', true, radarrServices, sonarrServices),
|
||||
[radarrServices, sonarrServices]
|
||||
);
|
||||
|
||||
const missingFallbacks = useMemo(
|
||||
() => ({
|
||||
radarrNon4k: !!radarrDefaultNon4k && !hasFallback(rules, 'radarr', false),
|
||||
radarr4k: !!radarrDefault4k && !hasFallback(rules, 'radarr', true),
|
||||
sonarrNon4k: !!sonarrDefaultNon4k && !hasFallback(rules, 'sonarr', false),
|
||||
sonarr4k: !!sonarrDefault4k && !hasFallback(rules, 'sonarr', true),
|
||||
}),
|
||||
[
|
||||
rules,
|
||||
radarrDefaultNon4k,
|
||||
radarrDefault4k,
|
||||
sonarrDefaultNon4k,
|
||||
sonarrDefault4k,
|
||||
]
|
||||
);
|
||||
|
||||
const getServiceInfos = useCallback(async () => {
|
||||
const results: (DVRTestResponse & { type: string; id: number })[] = [];
|
||||
const allServices = [
|
||||
...radarrServices.map((s) => ({ ...s, _type: 'radarr' as const })),
|
||||
...sonarrServices.map((s) => ({ ...s, _type: 'sonarr' as const })),
|
||||
];
|
||||
|
||||
for (const service of allServices) {
|
||||
try {
|
||||
const response = await axios.post<DVRTestResponse>(
|
||||
`/api/v1/settings/${service._type}/test`,
|
||||
{
|
||||
hostname: service.hostname,
|
||||
apiKey: service.apiKey,
|
||||
port: Number(service.port),
|
||||
baseUrl: service.baseUrl,
|
||||
useSsl: service.useSsl,
|
||||
}
|
||||
);
|
||||
results.push({
|
||||
type: service._type,
|
||||
id: service.id,
|
||||
...response.data,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
type: service._type,
|
||||
id: service.id,
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
setTestResponses(results);
|
||||
}, [radarrServices, sonarrServices]);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceInfos();
|
||||
}, [getServiceInfos]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const allKeywordIds = rules
|
||||
.map((rule) => rule.keywords?.split(','))
|
||||
.flat()
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
if (allKeywordIds.length > 0) {
|
||||
const keywordResults = await Promise.all(
|
||||
[...new Set(allKeywordIds)].map(async (id) => {
|
||||
try {
|
||||
const response = await axios.get<Keyword | null>(
|
||||
`/api/v1/keyword/${id}`
|
||||
);
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
setKeywordsData(keywordResults.filter((k): k is Keyword => k !== null));
|
||||
}
|
||||
|
||||
const allUserIds = rules
|
||||
.map((rule) => rule.users)
|
||||
.filter((u): u is string => !!u)
|
||||
.join(',');
|
||||
|
||||
if (allUserIds) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/api/v1/user?includeIds=${encodeURIComponent(allUserIds)}`
|
||||
);
|
||||
setUsers(response.data.results);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [rules]);
|
||||
|
||||
const sortedRules = useMemo(() => {
|
||||
return [...rules].sort((a, b) => {
|
||||
const aFallback = isFallbackRule(a);
|
||||
const bFallback = isFallbackRule(b);
|
||||
|
||||
if (aFallback && !bFallback) return 1;
|
||||
if (!aFallback && bFallback) return -1;
|
||||
|
||||
if (aFallback && bFallback) {
|
||||
const a4k = !!a.is4k;
|
||||
const b4k = !!b.is4k;
|
||||
if (a4k !== b4k) return a4k ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return b.priority - a.priority;
|
||||
});
|
||||
}, [rules]);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() =>
|
||||
sortedRules.filter((r) => filter === 'all' || r.serviceType === filter),
|
||||
[sortedRules, filter]
|
||||
);
|
||||
|
||||
const displayRules = localOrder ?? filteredRules;
|
||||
|
||||
const counts = {
|
||||
all: rules.length,
|
||||
sonarr: rules.filter((r) => r.serviceType === 'sonarr').length,
|
||||
radarr: rules.filter((r) => r.serviceType === 'radarr').length,
|
||||
};
|
||||
|
||||
const openMissingFallbackIfAny = () => {
|
||||
const pickForService = (svc: ServiceType) => {
|
||||
if (svc === 'radarr') {
|
||||
if (missingFallbacks.radarrNon4k && radarrDefaultNon4k) {
|
||||
return {
|
||||
serviceType: 'radarr' as const,
|
||||
is4k: false,
|
||||
instance: radarrDefaultNon4k,
|
||||
};
|
||||
}
|
||||
if (missingFallbacks.radarr4k && radarrDefault4k) {
|
||||
return {
|
||||
serviceType: 'radarr' as const,
|
||||
is4k: true,
|
||||
instance: radarrDefault4k,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (missingFallbacks.sonarrNon4k && sonarrDefaultNon4k) {
|
||||
return {
|
||||
serviceType: 'sonarr' as const,
|
||||
is4k: false,
|
||||
instance: sonarrDefaultNon4k,
|
||||
};
|
||||
}
|
||||
if (missingFallbacks.sonarr4k && sonarrDefault4k) {
|
||||
return {
|
||||
serviceType: 'sonarr' as const,
|
||||
is4k: true,
|
||||
instance: sonarrDefault4k,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let target: {
|
||||
serviceType: ServiceType;
|
||||
is4k: boolean;
|
||||
instance: RadarrSettings | SonarrSettings;
|
||||
} | null = null;
|
||||
|
||||
if (filter === 'radarr') target = pickForService('radarr');
|
||||
else if (filter === 'sonarr') target = pickForService('sonarr');
|
||||
else {
|
||||
target = pickForService('radarr') ?? pickForService('sonarr');
|
||||
}
|
||||
|
||||
if (!target) return false;
|
||||
|
||||
onAddRule({
|
||||
name: `${target.instance.name} Default Route`,
|
||||
serviceType: target.serviceType,
|
||||
is4k: target.is4k,
|
||||
targetServiceId: target.instance.id,
|
||||
isFallback: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const missingAnimeRule = useMemo(() => {
|
||||
const hasSonarrFallback = (is4k: boolean) =>
|
||||
rules.some(
|
||||
(r) => r.serviceType === 'sonarr' && r.isFallback && !!r.is4k === is4k
|
||||
);
|
||||
const hasAnimeRule = (is4k: boolean) =>
|
||||
rules.some(
|
||||
(r) =>
|
||||
r.serviceType === 'sonarr' &&
|
||||
!!r.is4k === is4k &&
|
||||
r.keywords?.includes('210024')
|
||||
);
|
||||
|
||||
return {
|
||||
non4k: hasSonarrFallback(false) && !hasAnimeRule(false),
|
||||
is4k: hasSonarrFallback(true) && !hasAnimeRule(true),
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
const handleAddRuleClick = () => {
|
||||
if (openMissingFallbackIfAny()) return;
|
||||
onAddRule();
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDragIndex(index);
|
||||
setLocalOrder([...filteredRules]);
|
||||
};
|
||||
|
||||
const handleDragOver = (
|
||||
e: React.DragEvent<HTMLDivElement>,
|
||||
index: number
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex === null || dragIndex === index || !localOrder) return;
|
||||
if (
|
||||
isFallbackRule(localOrder[index]) ||
|
||||
isFallbackRule(localOrder[dragIndex])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reordered = [...localOrder];
|
||||
const [moved] = reordered.splice(dragIndex, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
setLocalOrder(reordered);
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
if (localOrder) {
|
||||
const nonFallbackIds = localOrder
|
||||
.filter((r) => !isFallbackRule(r))
|
||||
.map((r) => r.id);
|
||||
|
||||
try {
|
||||
await axios.post('/api/v1/routingRule/reorder', {
|
||||
ruleIds: nonFallbackIds,
|
||||
});
|
||||
revalidate();
|
||||
} catch {
|
||||
revalidate();
|
||||
}
|
||||
}
|
||||
setDragIndex(null);
|
||||
setLocalOrder(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (rule: RoutingRule) => {
|
||||
setDeleteModal({ open: true, rule });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteModal.rule) return;
|
||||
try {
|
||||
await axios.delete(`/api/v1/routingRule/${deleteModal.rule.id}`);
|
||||
revalidate();
|
||||
if (expandedId === deleteModal.rule.id) {
|
||||
setExpandedId(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeleteModal({ open: false, rule: null });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={deleteModal.open}
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.deleteRule)}
|
||||
okText={intl.formatMessage(globalMessages.delete)}
|
||||
okButtonType="danger"
|
||||
onOk={() => confirmDelete()}
|
||||
onCancel={() => setDeleteModal({ open: false, rule: null })}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteConfirm)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.routingRules)}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
disabled={
|
||||
radarrServices.length === 0 && sonarrServices.length === 0
|
||||
}
|
||||
onClick={handleAddRuleClick}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addRule)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.routingRulesDescription)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-gray-800 p-1 ring-1 ring-gray-700">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: messages.all },
|
||||
{ key: 'sonarr', label: messages.sonarr },
|
||||
{ key: 'radarr', label: messages.radarr },
|
||||
] as const
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setFilter(tab.key)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
|
||||
filter === tab.key
|
||||
? 'bg-gray-700 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(tab.label)}
|
||||
<span
|
||||
className={`ml-1.5 ${
|
||||
filter === tab.key ? 'text-gray-400' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{counts[tab.key]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sonarrDefaultNon4k && missingFallbacks.sonarrNon4k && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-200">
|
||||
{intl.formatMessage(messages.noFallbackWarning, {
|
||||
serviceType: 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{sonarrDefault4k && missingFallbacks.sonarr4k && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-200">
|
||||
{intl.formatMessage(messages.noFallbackWarning, {
|
||||
serviceType: 'Sonarr (4K)',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingAnimeRule.non4k && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-lg bg-blue-900/20 px-4 py-2 ring-1 ring-blue-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<InformationCircleIcon className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm text-blue-200">
|
||||
{intl.formatMessage(messages.animeRuleSuggestion)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
const sonarrFallback = rules.find(
|
||||
(r) => r.serviceType === 'sonarr' && r.isFallback && !r.is4k
|
||||
);
|
||||
onAddRule({
|
||||
name: 'Anime',
|
||||
serviceType: 'sonarr',
|
||||
is4k: false,
|
||||
targetServiceId: sonarrFallback?.targetServiceId,
|
||||
keywords: '210024',
|
||||
seriesType: 'anime',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.addAnimeRule)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{radarrDefaultNon4k && missingFallbacks.radarrNon4k && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-200">
|
||||
{intl.formatMessage(messages.noFallbackWarning, {
|
||||
serviceType: 'Radarr',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{radarrDefault4k && missingFallbacks.radarr4k && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-200">
|
||||
{intl.formatMessage(messages.noFallbackWarning, {
|
||||
serviceType: 'Radarr (4K)',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{displayRules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
draggable={!isFallbackRule(rule)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
>
|
||||
<RoutingRuleRow
|
||||
rule={rule}
|
||||
index={index}
|
||||
expanded={expandedId === rule.id}
|
||||
isDragging={dragIndex === index}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === rule.id ? null : rule.id)
|
||||
}
|
||||
onEdit={() => onEditRule(rule)}
|
||||
onDelete={() => handleDelete(rule)}
|
||||
dragHandleProps={{}}
|
||||
users={users}
|
||||
genres={genres}
|
||||
languages={languages}
|
||||
keywords={keywordsData}
|
||||
radarrServices={radarrServices}
|
||||
sonarrServices={sonarrServices}
|
||||
testResponses={testResponses}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredRules.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-gray-800 p-4 ring-1 ring-gray-700">
|
||||
<div className="flex gap-3">
|
||||
<InformationCircleIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-500" />
|
||||
<div className="text-xs leading-relaxed text-gray-500">
|
||||
<span className="font-medium text-gray-400">
|
||||
{intl.formatMessage(messages.howRoutingWorks)}
|
||||
</span>{' '}
|
||||
{intl.formatMessage(messages.routingExplainer)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoutingRuleList;
|
||||
797
src/components/Settings/RoutingRule/RoutingRuleModal.tsx
Normal file
797
src/components/Settings/RoutingRule/RoutingRuleModal.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import {
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
UserSelector,
|
||||
} from '@app/components/Selector';
|
||||
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
|
||||
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type OptionType = { value: number; label: string };
|
||||
type ServiceType = 'radarr' | 'sonarr';
|
||||
|
||||
const messages = defineMessages('components.Settings.RoutingRuleModal', {
|
||||
createRule: 'New Routing Rule',
|
||||
editRule: 'Edit Routing Rule',
|
||||
create: 'Create Rule',
|
||||
ruleName: 'Rule Name',
|
||||
ruleNamePlaceholder: 'e.g. Anime Content, Japanese Dramas',
|
||||
serviceType: 'Service Type',
|
||||
targetInstance: 'Target Instance',
|
||||
selectInstance: 'Select instance',
|
||||
firstInstanceSetup: 'First instance setup!',
|
||||
firstInstanceSetup4k: 'First 4K instance setup!',
|
||||
firstInstanceSetupBody:
|
||||
'We’re creating a fallback rule that catches all {mediaType} requests. You can customize defaults below or save to use instance defaults.',
|
||||
fallbackMustBeDefault: 'Fallback rules must target a default instance.',
|
||||
fallbackMustBe4k:
|
||||
'This fallback is for 4K requests, so it must target a 4K instance.',
|
||||
nonFallbackNeedsCondition:
|
||||
'Non-fallback rules must have at least one condition.',
|
||||
conditions: 'Conditions',
|
||||
conditionsDescription:
|
||||
'All condition types must match (AND). Within each type, any value can match (OR). Leave all empty for a fallback rule.',
|
||||
target: 'Target Settings',
|
||||
targetDescription:
|
||||
'Override settings for the target instance. Leave empty to use instance defaults.',
|
||||
users: 'Users',
|
||||
genres: 'Genres',
|
||||
languages: 'Languages',
|
||||
keywords: 'Keywords',
|
||||
rootFolder: 'Root Folder',
|
||||
selectRootFolder: 'Select root folder',
|
||||
qualityProfile: 'Quality Profile',
|
||||
selectQualityProfile: 'Select quality profile',
|
||||
minimumAvailability: 'Minimum Availability',
|
||||
announced: 'Announced',
|
||||
inCinemas: 'In Cinemas',
|
||||
released: 'Released',
|
||||
seriesType: 'Series Type',
|
||||
tags: 'Tags',
|
||||
selectTags: 'Select tags',
|
||||
noTagOptions: 'No tags.',
|
||||
badgeDefault: 'Default',
|
||||
badge4k: '4K',
|
||||
conditionalShouldNotBeDefault:
|
||||
'Conditional rules should target a non-default instance.',
|
||||
ruleCreated: 'Routing rule created successfully!',
|
||||
ruleUpdated: 'Routing rule updated successfully!',
|
||||
validationNameRequired: 'You must provide a rule name',
|
||||
validationTargetRequired: 'You must select a target instance',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
validationProfileRequired: 'You must select a quality profile',
|
||||
validationMinimumAvailabilityRequired:
|
||||
'You must select a minimum availability',
|
||||
});
|
||||
|
||||
interface RoutingRuleModalProps {
|
||||
rule: RoutingRule | null;
|
||||
onClose: () => void;
|
||||
radarrServices: RadarrSettings[];
|
||||
sonarrServices: SonarrSettings[];
|
||||
prefillData?: Partial<RoutingRule>;
|
||||
}
|
||||
|
||||
const RoutingRuleModal = ({
|
||||
onClose,
|
||||
rule,
|
||||
radarrServices,
|
||||
sonarrServices,
|
||||
prefillData,
|
||||
}: RoutingRuleModalProps) => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { currentSettings } = useSettings();
|
||||
const [isValidated, setIsValidated] = useState(!!rule);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const isFallbackMode = !!(rule?.isFallback || prefillData?.isFallback);
|
||||
const isPrefillFallback = !rule && !!prefillData?.isFallback;
|
||||
const requires4kFallback = !!(
|
||||
isFallbackMode &&
|
||||
(rule?.is4k || prefillData?.is4k)
|
||||
);
|
||||
|
||||
const getServiceInfos = useCallback(
|
||||
async (service: RadarrSettings | SonarrSettings, type: ServiceType) => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const response = await axios.post<DVRTestResponse>(
|
||||
`/api/v1/settings/${type}/test`,
|
||||
{
|
||||
hostname: service.hostname,
|
||||
apiKey: service.apiKey,
|
||||
port: Number(service.port),
|
||||
baseUrl: service.baseUrl,
|
||||
useSsl: service.useSsl,
|
||||
}
|
||||
);
|
||||
|
||||
setIsValidated(true);
|
||||
setTestResponse(response.data);
|
||||
} catch {
|
||||
setIsValidated(false);
|
||||
setTestResponse({ profiles: [], rootFolders: [], tags: [] });
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const data = rule ?? prefillData;
|
||||
if (!data?.serviceType || data.targetServiceId == null) return;
|
||||
|
||||
const services =
|
||||
data.serviceType === 'radarr' ? radarrServices : sonarrServices;
|
||||
const svc = services.find((s) => s.id === data.targetServiceId);
|
||||
if (!svc) return;
|
||||
|
||||
getServiceInfos(svc, data.serviceType);
|
||||
}, [rule, prefillData, radarrServices, sonarrServices, getServiceInfos]);
|
||||
|
||||
const RoutingRuleSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
),
|
||||
targetServiceId: Yup.number()
|
||||
.required(intl.formatMessage(messages.validationTargetRequired))
|
||||
.min(0, intl.formatMessage(messages.validationTargetRequired)),
|
||||
isFallback: Yup.boolean().default(isFallbackMode),
|
||||
rootFolder: Yup.string().when('isFallback', {
|
||||
is: true,
|
||||
then: (s) =>
|
||||
s.required(intl.formatMessage(messages.validationRootFolderRequired)),
|
||||
otherwise: (s) => s.nullable(),
|
||||
}),
|
||||
activeProfileId: Yup.number()
|
||||
.transform((val, orig) =>
|
||||
orig === '' || orig == null ? null : Number(orig)
|
||||
)
|
||||
.nullable()
|
||||
.when('isFallback', {
|
||||
is: true,
|
||||
then: (s) =>
|
||||
s.required(intl.formatMessage(messages.validationProfileRequired)),
|
||||
otherwise: (s) => s.nullable(),
|
||||
}),
|
||||
minimumAvailability: Yup.string().when(['isFallback', 'serviceType'], {
|
||||
is: (isFallback: boolean, serviceType: ServiceType) =>
|
||||
isFallback && serviceType === 'radarr',
|
||||
then: (s) =>
|
||||
s.required(
|
||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||
),
|
||||
otherwise: (s) => s.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const getDerivedFlags = (svc?: RadarrSettings | SonarrSettings) => {
|
||||
const isDefault = !!(svc && svc.isDefault);
|
||||
const is4k = !!(svc && svc.is4k);
|
||||
return { isDefault, is4k };
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: rule?.name ?? prefillData?.name ?? '',
|
||||
serviceType: (rule?.serviceType ??
|
||||
prefillData?.serviceType ??
|
||||
'sonarr') as ServiceType,
|
||||
targetServiceId:
|
||||
rule?.targetServiceId ?? prefillData?.targetServiceId ?? -1,
|
||||
isFallback: isFallbackMode,
|
||||
users: rule?.users ?? prefillData?.users ?? undefined,
|
||||
genres: rule?.genres ?? prefillData?.genres ?? undefined,
|
||||
languages: rule?.languages ?? prefillData?.languages ?? undefined,
|
||||
keywords: rule?.keywords ?? prefillData?.keywords ?? undefined,
|
||||
activeProfileId:
|
||||
rule?.activeProfileId ?? prefillData?.activeProfileId ?? undefined,
|
||||
rootFolder: rule?.rootFolder ?? prefillData?.rootFolder ?? undefined,
|
||||
minimumAvailability:
|
||||
rule?.minimumAvailability ??
|
||||
prefillData?.minimumAvailability ??
|
||||
'released',
|
||||
seriesType: rule?.seriesType ?? prefillData?.seriesType ?? undefined,
|
||||
tags: rule?.tags ?? prefillData?.tags ?? undefined,
|
||||
}}
|
||||
validationSchema={RoutingRuleSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const services =
|
||||
values.serviceType === 'radarr' ? radarrServices : sonarrServices;
|
||||
const selectedService = services.find(
|
||||
(s) => s.id === values.targetServiceId
|
||||
);
|
||||
const derived = getDerivedFlags(selectedService);
|
||||
|
||||
const activeProfileId =
|
||||
values.activeProfileId == null
|
||||
? null
|
||||
: Number(values.activeProfileId);
|
||||
|
||||
const profileName =
|
||||
testResponse.profiles.find(
|
||||
(p) => p.id === Number(values.activeProfileId)
|
||||
)?.name ?? null;
|
||||
|
||||
const submission = {
|
||||
name: values.name,
|
||||
serviceType: values.serviceType,
|
||||
targetServiceId: values.targetServiceId,
|
||||
isFallback: values.isFallback,
|
||||
is4k: derived.is4k,
|
||||
users: values.isFallback ? null : values.users || null,
|
||||
genres: values.isFallback ? null : values.genres || null,
|
||||
languages: values.isFallback ? null : values.languages || null,
|
||||
keywords: values.isFallback ? null : values.keywords || null,
|
||||
activeProfileId,
|
||||
activeProfileName: profileName,
|
||||
rootFolder: values.rootFolder || null,
|
||||
minimumAvailability:
|
||||
values.serviceType === 'radarr'
|
||||
? values.minimumAvailability || null
|
||||
: null,
|
||||
seriesType:
|
||||
values.serviceType === 'sonarr'
|
||||
? values.seriesType || null
|
||||
: null,
|
||||
tags: values.tags || null,
|
||||
};
|
||||
|
||||
if (!rule) {
|
||||
await axios.post('/api/v1/routingRule', submission);
|
||||
addToast(intl.formatMessage(messages.ruleCreated), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} else {
|
||||
await axios.put(`/api/v1/routingRule/${rule.id}`, submission);
|
||||
addToast(intl.formatMessage(messages.ruleUpdated), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
// TODO: handle error
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
const services =
|
||||
values.serviceType === 'radarr' ? radarrServices : sonarrServices;
|
||||
const selectedService = services.find(
|
||||
(s) => s.id === values.targetServiceId
|
||||
);
|
||||
const derived = getDerivedFlags(selectedService);
|
||||
|
||||
const hasAnyCondition = !!(
|
||||
values.users ||
|
||||
values.genres ||
|
||||
values.languages ||
|
||||
values.keywords
|
||||
);
|
||||
|
||||
const fallbackTargetOk =
|
||||
derived.isDefault && (!requires4kFallback || derived.is4k);
|
||||
|
||||
const canSave =
|
||||
isValid &&
|
||||
isValidated &&
|
||||
(!values.isFallback ? hasAnyCondition : fallbackTargetOk);
|
||||
|
||||
const optionsDisabled = !isValidated || isTesting;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onCancel={onClose}
|
||||
okButtonType="primary"
|
||||
okText={
|
||||
isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: rule
|
||||
? intl.formatMessage(globalMessages.save)
|
||||
: intl.formatMessage(messages.create)
|
||||
}
|
||||
okDisabled={isSubmitting || !isValid || !canSave}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!rule
|
||||
? intl.formatMessage(messages.createRule)
|
||||
: intl.formatMessage(messages.editRule)
|
||||
}
|
||||
>
|
||||
<div className="mb-6">
|
||||
{isPrefillFallback && (
|
||||
<div className="mb-4 rounded-lg border border-blue-500/30 bg-blue-900/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<InformationCircleIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<div className="text-sm text-blue-200">
|
||||
<strong>
|
||||
{intl.formatMessage(
|
||||
requires4kFallback
|
||||
? messages.firstInstanceSetup4k
|
||||
: messages.firstInstanceSetup
|
||||
)}
|
||||
</strong>{' '}
|
||||
{intl.formatMessage(messages.firstInstanceSetupBody, {
|
||||
mediaType:
|
||||
values.serviceType === 'radarr' ? 'movie' : 'TV',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.ruleName)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.ruleNamePlaceholder
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.name &&
|
||||
touched.name &&
|
||||
typeof errors.name === 'string' && (
|
||||
<div className="error">{errors.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="serviceType" className="text-label">
|
||||
{intl.formatMessage(messages.serviceType)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="serviceType"
|
||||
name="serviceType"
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setFieldValue('serviceType', e.target.value);
|
||||
setFieldValue('targetServiceId', -1);
|
||||
setIsValidated(false);
|
||||
setTestResponse({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
setFieldValue('activeProfileId', undefined);
|
||||
setFieldValue('rootFolder', undefined);
|
||||
setFieldValue('tags', undefined);
|
||||
setFieldValue('seriesType', undefined);
|
||||
setFieldValue('minimumAvailability', 'released');
|
||||
}}
|
||||
>
|
||||
<option value="sonarr">Sonarr</option>
|
||||
<option value="radarr">Radarr</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="targetServiceId" className="text-label">
|
||||
{intl.formatMessage(messages.targetInstance)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="targetServiceId"
|
||||
name="targetServiceId"
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const id = Number(e.target.value);
|
||||
setFieldValue('targetServiceId', id);
|
||||
|
||||
const svc = services.find((s) => s.id === id);
|
||||
if (svc) {
|
||||
getServiceInfos(svc, values.serviceType);
|
||||
} else {
|
||||
setIsValidated(false);
|
||||
setTestResponse({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value={-1}>
|
||||
{intl.formatMessage(messages.selectInstance)}
|
||||
</option>
|
||||
{services.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selectedService && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{derived.isDefault && (
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(messages.badgeDefault)}
|
||||
</Badge>
|
||||
)}
|
||||
{derived.is4k && (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(messages.badge4k)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.isFallback &&
|
||||
values.targetServiceId >= 0 &&
|
||||
!derived.isDefault && (
|
||||
<div className="mt-2 rounded-md border border-red-500/30 bg-red-900/10 p-2 text-sm text-red-200">
|
||||
{intl.formatMessage(messages.fallbackMustBeDefault)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.isFallback &&
|
||||
requires4kFallback &&
|
||||
values.targetServiceId >= 0 &&
|
||||
derived.isDefault &&
|
||||
!derived.is4k && (
|
||||
<div className="mt-2 rounded-md border border-red-500/30 bg-red-900/10 p-2 text-sm text-red-200">
|
||||
{intl.formatMessage(messages.fallbackMustBe4k)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!values.isFallback && !hasAnyCondition && (
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.nonFallbackNeedsCondition)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.targetServiceId &&
|
||||
touched.targetServiceId &&
|
||||
typeof errors.targetServiceId === 'string' && (
|
||||
<div className="error">{errors.targetServiceId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!values.isFallback && (
|
||||
<>
|
||||
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.conditionsDescription)}
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="users" className="text-label">
|
||||
{intl.formatMessage(messages.users)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<UserSelector
|
||||
defaultValue={values.users}
|
||||
isMulti
|
||||
onChange={(selectedUsers) => {
|
||||
setFieldValue(
|
||||
'users',
|
||||
selectedUsers?.map((v) => v.value).join(',') ||
|
||||
undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="genres" className="text-label">
|
||||
{intl.formatMessage(messages.genres)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<GenreSelector
|
||||
type={
|
||||
values.serviceType === 'radarr' ? 'movie' : 'tv'
|
||||
}
|
||||
defaultValue={values.genres}
|
||||
isMulti
|
||||
onChange={(selectedGenres) => {
|
||||
setFieldValue(
|
||||
'genres',
|
||||
selectedGenres?.map((v) => v.value).join(',') ||
|
||||
undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="languages" className="text-label">
|
||||
{intl.formatMessage(messages.languages)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<LanguageSelector
|
||||
value={values.languages}
|
||||
serverValue={currentSettings.originalLanguage}
|
||||
setFieldValue={(_key, value) => {
|
||||
setFieldValue('languages', value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="keywords" className="text-label">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<KeywordSelector
|
||||
defaultValue={values.keywords}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'keywords',
|
||||
value?.map((v) => v.value).join(',') ||
|
||||
undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.target)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.targetDescription)}
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="rootFolder" className="text-label">
|
||||
{intl.formatMessage(messages.rootFolder)}
|
||||
{values.isFallback && (
|
||||
<span className="label-required">*</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
disabled={optionsDisabled}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.map((folder) => (
|
||||
<option key={folder.id} value={folder.path}>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.rootFolder &&
|
||||
touched.rootFolder &&
|
||||
typeof errors.rootFolder === 'string' && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeProfileId" className="text-label">
|
||||
{intl.formatMessage(messages.qualityProfile)}
|
||||
{values.isFallback && (
|
||||
<span className="label-required">*</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
disabled={optionsDisabled}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeProfileId &&
|
||||
touched.activeProfileId &&
|
||||
typeof errors.activeProfileId === 'string' && (
|
||||
<div className="error">{errors.activeProfileId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{values.serviceType === 'radarr' && (
|
||||
<div className="form-row">
|
||||
<label htmlFor="minimumAvailability" className="text-label">
|
||||
{intl.formatMessage(messages.minimumAvailability)}
|
||||
{values.isFallback && (
|
||||
<span className="label-required">*</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="minimumAvailability"
|
||||
name="minimumAvailability"
|
||||
disabled={optionsDisabled}
|
||||
>
|
||||
<option value="announced">
|
||||
{intl.formatMessage(messages.announced)}
|
||||
</option>
|
||||
<option value="inCinemas">
|
||||
{intl.formatMessage(messages.inCinemas)}
|
||||
</option>
|
||||
<option value="released">
|
||||
{intl.formatMessage(messages.released)}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.minimumAvailability &&
|
||||
touched.minimumAvailability &&
|
||||
typeof errors.minimumAvailability === 'string' && (
|
||||
<div className="error">
|
||||
{errors.minimumAvailability}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.serviceType === 'sonarr' && (
|
||||
<div className="form-row">
|
||||
<label htmlFor="seriesType" className="text-label">
|
||||
{intl.formatMessage(messages.seriesType)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="seriesType"
|
||||
name="seriesType"
|
||||
disabled={optionsDisabled}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="anime">Anime</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Select<OptionType, true>
|
||||
options={
|
||||
isValidated
|
||||
? testResponse.tags.map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
isMulti
|
||||
isDisabled={optionsDisabled}
|
||||
placeholder={intl.formatMessage(messages.selectTags)}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
(values.tags
|
||||
?.split(',')
|
||||
.map((tagId) => {
|
||||
const foundTag = testResponse.tags.find(
|
||||
(tag) => tag.id === Number(tagId)
|
||||
);
|
||||
if (!foundTag) return undefined;
|
||||
return {
|
||||
value: foundTag.id,
|
||||
label: foundTag.label,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(option) => option !== undefined
|
||||
) as OptionType[]) ?? []
|
||||
}
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'tags',
|
||||
value.map((option) => option.value).join(',') ||
|
||||
undefined
|
||||
);
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.noTagOptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoutingRuleModal;
|
||||
555
src/components/Settings/RoutingRule/RoutingRuleRow.tsx
Normal file
555
src/components/Settings/RoutingRule/RoutingRuleRow.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
|
||||
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type {
|
||||
Language,
|
||||
RadarrSettings,
|
||||
SonarrSettings,
|
||||
} from '@server/lib/settings';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Settings.RoutingRuleRow', {
|
||||
fallback: 'Fallback',
|
||||
conditions: 'Conditions',
|
||||
routeTo: 'Route To',
|
||||
matchesAll: 'Matches all requests',
|
||||
instanceDefaults: 'Uses instance defaults',
|
||||
instance: 'Instance',
|
||||
rootFolder: 'Root Folder',
|
||||
qualityProfile: 'Quality Profile',
|
||||
minimumAvailability: 'Minimum Availability',
|
||||
seriesType: 'Series Type',
|
||||
tags: 'Tags',
|
||||
users: 'Users',
|
||||
genres: 'Genres',
|
||||
languages: 'Languages',
|
||||
keywords: 'Keywords',
|
||||
sonarr: 'Sonarr',
|
||||
radarr: 'Radarr',
|
||||
});
|
||||
|
||||
interface RoutingRuleRowProps {
|
||||
rule: RoutingRule;
|
||||
index: number;
|
||||
expanded: boolean;
|
||||
isDragging: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
dragHandleProps: Record<string, unknown>;
|
||||
users?: User[];
|
||||
genres?: TmdbGenre[];
|
||||
languages?: Language[];
|
||||
keywords?: Keyword[];
|
||||
radarrServices: RadarrSettings[];
|
||||
sonarrServices: SonarrSettings[];
|
||||
testResponses: (DVRTestResponse & { type: string; id: number })[];
|
||||
}
|
||||
|
||||
const ConditionBadges = ({
|
||||
rule,
|
||||
users,
|
||||
genres,
|
||||
languages,
|
||||
keywords,
|
||||
}: {
|
||||
rule: RoutingRule;
|
||||
users?: User[];
|
||||
genres?: TmdbGenre[];
|
||||
languages?: Language[];
|
||||
keywords?: Keyword[];
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const hasConditions =
|
||||
!!rule.users || !!rule.genres || !!rule.languages || !!rule.keywords;
|
||||
|
||||
if (!hasConditions) {
|
||||
return (
|
||||
<span className="text-sm italic text-gray-500">
|
||||
{intl.formatMessage(messages.matchesAll)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{rule.keywords
|
||||
?.split(',')
|
||||
.filter(Boolean)
|
||||
.map((keywordId) => {
|
||||
const keyword = keywords?.find((k) => k.id === Number(keywordId));
|
||||
return (
|
||||
<Badge key={`kw-${keywordId}`} badgeType="warning">
|
||||
{keyword?.name ?? keywordId}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
{rule.genres
|
||||
?.split(',')
|
||||
.filter(Boolean)
|
||||
.map((genreId) => {
|
||||
const genre = genres?.find((g) => g.id === Number(genreId));
|
||||
return (
|
||||
<Badge key={`g-${genreId}`} badgeType="warning">
|
||||
{genre?.name ?? genreId}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
{rule.languages
|
||||
?.split('|')
|
||||
.filter((l) => l && l !== 'server')
|
||||
.map((langCode) => {
|
||||
const lang = languages?.find((l) => l.iso_639_1 === langCode);
|
||||
const name =
|
||||
intl.formatDisplayName(langCode, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ??
|
||||
lang?.english_name ??
|
||||
langCode;
|
||||
|
||||
return (
|
||||
<Badge key={`l-${langCode}`} badgeType="success">
|
||||
{name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
{rule.users
|
||||
?.split(',')
|
||||
.filter(Boolean)
|
||||
.map((userId) => {
|
||||
const user = users?.find((u) => u.id === Number(userId));
|
||||
return (
|
||||
<Badge key={`u-${userId}`}>{user?.displayName ?? userId}</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TargetBadges = ({
|
||||
rule,
|
||||
radarrServices,
|
||||
sonarrServices,
|
||||
testResponses,
|
||||
}: {
|
||||
rule: RoutingRule;
|
||||
radarrServices: RadarrSettings[];
|
||||
sonarrServices: SonarrSettings[];
|
||||
testResponses: (DVRTestResponse & { type: string; id: number })[];
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const services =
|
||||
rule.serviceType === 'sonarr' ? sonarrServices : radarrServices;
|
||||
const targetService = services.find((s) => s.id === rule.targetServiceId);
|
||||
const testResponse = testResponses.find(
|
||||
(r) => r.id === rule.targetServiceId && r.type === rule.serviceType
|
||||
);
|
||||
|
||||
const profileName =
|
||||
(rule.activeProfileId != null
|
||||
? testResponse?.profiles.find(
|
||||
(p) => p.id === Number(rule.activeProfileId)
|
||||
)?.name
|
||||
: null) ??
|
||||
rule.activeProfileName ??
|
||||
null;
|
||||
|
||||
const hasOverrides = Boolean(
|
||||
rule.rootFolder ||
|
||||
rule.activeProfileId != null ||
|
||||
rule.seriesType ||
|
||||
rule.tags ||
|
||||
(rule.serviceType === 'radarr' && rule.minimumAvailability)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge badgeType="primary">{targetService?.name ?? 'Unknown'}</Badge>
|
||||
{rule.rootFolder && <Badge>{rule.rootFolder}</Badge>}
|
||||
{rule.activeProfileId != null && (
|
||||
<Badge>{profileName ?? String(rule.activeProfileId)}</Badge>
|
||||
)}
|
||||
{rule.seriesType && <Badge badgeType="warning">{rule.seriesType}</Badge>}
|
||||
{rule.tags?.split(',').map((tagId) => {
|
||||
const tag = testResponse?.tags.find((t) => t.id === Number(tagId));
|
||||
return <Badge key={`t-${tagId}`}>{tag?.label ?? tagId}</Badge>;
|
||||
})}
|
||||
{!hasOverrides && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.instanceDefaults)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DragHandle = (props: Record<string, unknown>) => (
|
||||
<div
|
||||
{...props}
|
||||
className="flex cursor-grab flex-col items-center justify-center gap-[3px] px-3 py-3 text-gray-600 transition-colors hover:text-gray-400 active:cursor-grabbing"
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex gap-[3px]">
|
||||
<div className="h-[3px] w-[3px] rounded-full bg-current" />
|
||||
<div className="h-[3px] w-[3px] rounded-full bg-current" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const RoutingRuleRow = ({
|
||||
rule,
|
||||
index,
|
||||
expanded,
|
||||
isDragging,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
dragHandleProps,
|
||||
users,
|
||||
genres,
|
||||
languages,
|
||||
keywords,
|
||||
radarrServices,
|
||||
sonarrServices,
|
||||
testResponses,
|
||||
}: RoutingRuleRowProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const isFallback = !!rule.isFallback;
|
||||
const services =
|
||||
rule.serviceType === 'sonarr' ? sonarrServices : radarrServices;
|
||||
const targetService = services.find((s) => s.id === rule.targetServiceId);
|
||||
const testResponse = testResponses.find(
|
||||
(r) => r.id === rule.targetServiceId && r.type === rule.serviceType
|
||||
);
|
||||
|
||||
const profileName =
|
||||
(rule.activeProfileId != null
|
||||
? testResponse?.profiles.find(
|
||||
(p) => p.id === Number(rule.activeProfileId)
|
||||
)?.name
|
||||
: null) ??
|
||||
rule.activeProfileName ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg transition-all duration-200 ${
|
||||
isDragging
|
||||
? 'scale-[1.01] bg-gray-700 shadow-lg ring-2 ring-indigo-500'
|
||||
: expanded
|
||||
? 'bg-gray-800 ring-1 ring-gray-500'
|
||||
: 'bg-gray-800 ring-1 ring-gray-700 hover:ring-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{!isFallback && <DragHandle {...dragHandleProps} />}
|
||||
{isFallback && <div className="w-9" />}
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 py-3 pr-4 text-left"
|
||||
>
|
||||
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-700 font-mono text-xs text-gray-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
{rule.name}
|
||||
</span>
|
||||
|
||||
{isFallback && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(messages.fallback)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
badgeType={rule.serviceType === 'sonarr' ? 'primary' : 'danger'}
|
||||
>
|
||||
{rule.serviceType === 'sonarr'
|
||||
? intl.formatMessage(messages.sonarr)
|
||||
: intl.formatMessage(messages.radarr)}
|
||||
</Badge>
|
||||
|
||||
{rule.is4k && <Badge badgeType="warning">4K</Badge>}
|
||||
</div>
|
||||
|
||||
{!expanded && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<ConditionBadges
|
||||
rule={rule}
|
||||
users={users}
|
||||
genres={genres}
|
||||
languages={languages}
|
||||
keywords={keywords}
|
||||
/>
|
||||
<span className="text-gray-600">→</span>
|
||||
<TargetBadges
|
||||
rule={rule}
|
||||
radarrServices={radarrServices}
|
||||
sonarrServices={sonarrServices}
|
||||
testResponses={testResponses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 flex-shrink-0 text-gray-500 transition-transform ${
|
||||
expanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-700 px-4 pb-4 pl-12">
|
||||
<div className="grid grid-cols-2 gap-6 pt-4">
|
||||
{/* Conditions */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</h4>
|
||||
|
||||
{!rule.users &&
|
||||
!rule.genres &&
|
||||
!rule.languages &&
|
||||
!rule.keywords ? (
|
||||
<p className="text-sm italic text-gray-500">
|
||||
{intl.formatMessage(messages.matchesAll)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rule.keywords && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="w-20 pt-0.5 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rule.keywords
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((keywordId) => {
|
||||
const keyword = keywords?.find(
|
||||
(k) => k.id === Number(keywordId)
|
||||
);
|
||||
return (
|
||||
<Badge key={keywordId} badgeType="warning">
|
||||
{keyword?.name ?? keywordId}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.genres && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="w-20 pt-0.5 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.genres)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rule.genres
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((genreId) => {
|
||||
const genre = genres?.find(
|
||||
(g) => g.id === Number(genreId)
|
||||
);
|
||||
return (
|
||||
<Badge key={genreId} badgeType="warning">
|
||||
{genre?.name ?? genreId}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.languages && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="w-20 pt-0.5 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.languages)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rule.languages
|
||||
.split('|')
|
||||
.filter((l) => l && l !== 'server')
|
||||
.map((langCode) => {
|
||||
const name =
|
||||
intl.formatDisplayName(langCode, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ?? langCode;
|
||||
return (
|
||||
<Badge key={langCode} badgeType="success">
|
||||
{name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.users && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="w-20 pt-0.5 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.users)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rule.users
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((userId) => {
|
||||
const user = users?.find(
|
||||
(u) => u.id === Number(userId)
|
||||
);
|
||||
return (
|
||||
<Badge key={userId}>
|
||||
{user?.displayName ?? userId}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{intl.formatMessage(messages.routeTo)}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.instance)}
|
||||
</span>
|
||||
<Badge badgeType="primary">
|
||||
{targetService?.name ?? 'Unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{rule.rootFolder && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.rootFolder)}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-gray-300">
|
||||
{rule.rootFolder}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.activeProfileId != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.qualityProfile)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300">
|
||||
{profileName ?? String(rule.activeProfileId)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.serviceType === 'radarr' && rule.minimumAvailability && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.minimumAvailability)}
|
||||
</span>
|
||||
<Badge badgeType="warning">
|
||||
{rule.minimumAvailability}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.seriesType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.seriesType)}
|
||||
</span>
|
||||
<Badge badgeType="warning">{rule.seriesType}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.tags && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs text-gray-500">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{rule.tags
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((tagId) => {
|
||||
const tag = testResponse?.tags.find(
|
||||
(t) => t.id === Number(tagId)
|
||||
);
|
||||
return (
|
||||
<Badge key={tagId}>{tag?.label ?? tagId}</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rule.rootFolder &&
|
||||
rule.activeProfileId == null &&
|
||||
!rule.minimumAvailability &&
|
||||
!rule.seriesType &&
|
||||
!rule.tags && (
|
||||
<p className="text-xs italic text-gray-500">
|
||||
{intl.formatMessage(messages.instanceDefaults)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-gray-700 pt-3">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent bg-gray-700 px-3 py-1.5 text-xs font-medium text-gray-200 transition duration-150 hover:text-white"
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
{intl.formatMessage(globalMessages.edit)}
|
||||
</button>
|
||||
|
||||
{!isFallback && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent px-3 py-1.5 text-xs font-medium text-gray-400 transition duration-150 hover:bg-red-900/20 hover:text-red-400"
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
{intl.formatMessage(globalMessages.delete)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoutingRuleRow;
|
||||
23
src/components/Settings/RoutingRule/types.ts
Normal file
23
src/components/Settings/RoutingRule/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface RoutingRule {
|
||||
id: number;
|
||||
serviceType: 'radarr' | 'sonarr';
|
||||
isFallback: boolean;
|
||||
is4k: boolean;
|
||||
priority: number;
|
||||
name: string;
|
||||
users?: string | null;
|
||||
genres?: string | null;
|
||||
languages?: string | null;
|
||||
keywords?: string | null;
|
||||
targetServiceId: number;
|
||||
activeProfileId?: number | null;
|
||||
activeProfileName: string | null;
|
||||
rootFolder?: string | null;
|
||||
seriesType?: string | null;
|
||||
tags?: string | null;
|
||||
minimumAvailability?: 'announced' | 'inCinemas' | 'released' | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type RoutingRuleResultsResponse = RoutingRule[];
|
||||
@@ -1,21 +1,19 @@
|
||||
import RadarrLogo from '@app/assets/services/radarr.svg';
|
||||
import SonarrLogo from '@app/assets/services/sonarr.svg';
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
|
||||
import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles';
|
||||
import RadarrModal from '@app/components/Settings/RadarrModal';
|
||||
import RoutingRuleList from '@app/components/Settings/RoutingRule/RoutingRuleList';
|
||||
import RoutingRuleModal from '@app/components/Settings/RoutingRule/RoutingRuleModal';
|
||||
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
|
||||
import SonarrModal from '@app/components/Settings/SonarrModal';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||
import type OverrideRule from '@server/entity/OverrideRule';
|
||||
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import axios from 'axios';
|
||||
import { Fragment, useState } from 'react';
|
||||
@@ -24,32 +22,22 @@ import useSWR, { mutate } from 'swr';
|
||||
|
||||
const messages = defineMessages('components.Settings', {
|
||||
services: 'Services',
|
||||
radarrsettings: 'Radarr Settings',
|
||||
sonarrsettings: 'Sonarr Settings',
|
||||
serviceSettingsDescription:
|
||||
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.',
|
||||
instances: 'Instances',
|
||||
instancesDescription:
|
||||
'Configure your Sonarr and Radarr server connections below. Routing rules determine which instance handles each request.',
|
||||
deleteserverconfirm: 'Are you sure you want to delete this server?',
|
||||
ssl: 'SSL',
|
||||
default: 'Default',
|
||||
default4k: 'Default 4K',
|
||||
is4k: '4K',
|
||||
address: 'Address',
|
||||
activeProfile: 'Active Profile',
|
||||
addradarr: 'Add Radarr Server',
|
||||
addsonarr: 'Add Sonarr Server',
|
||||
noDefaultServer:
|
||||
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
|
||||
noDefaultNon4kServer:
|
||||
'If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.',
|
||||
noDefault4kServer:
|
||||
'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.',
|
||||
mediaTypeMovie: 'movie',
|
||||
mediaTypeSeries: 'series',
|
||||
routingRules: 'Routing Rules',
|
||||
noRules: 'No routing rules configured',
|
||||
ruleCount: '{count} routing {count, plural, one {rule} other {rules}}',
|
||||
addInstance: 'Add Instance',
|
||||
addRadarr: 'Add Radarr',
|
||||
addSonarr: 'Add Sonarr',
|
||||
deleteServer: 'Delete {serverType} Server',
|
||||
overrideRules: 'Override Rules',
|
||||
overrideRulesDescription:
|
||||
'Override rules allow you to specify properties that will be replaced if a request matches the rule.',
|
||||
addrule: 'New Override Rule',
|
||||
});
|
||||
|
||||
interface ServerInstanceProps {
|
||||
@@ -60,7 +48,7 @@ interface ServerInstanceProps {
|
||||
port: number;
|
||||
isSSL?: boolean;
|
||||
externalUrl?: string;
|
||||
profileName: string;
|
||||
ruleCount: number;
|
||||
isSonarr?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -97,11 +85,11 @@ const ServerInstance = ({
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
profileName,
|
||||
is4k = false,
|
||||
isDefault = false,
|
||||
is4k = false,
|
||||
isSSL = false,
|
||||
isSonarr = false,
|
||||
ruleCount,
|
||||
externalUrl,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -159,9 +147,11 @@ const ServerInstance = ({
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.activeProfile)}
|
||||
{intl.formatMessage(messages.routingRules)}
|
||||
</span>
|
||||
{profileName}
|
||||
{ruleCount === 0
|
||||
? intl.formatMessage(messages.noRules)
|
||||
: intl.formatMessage(messages.ruleCount, { count: ruleCount })}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
@@ -215,8 +205,10 @@ const SettingsServices = () => {
|
||||
error: sonarrError,
|
||||
mutate: revalidateSonarr,
|
||||
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
|
||||
const { data: rules, mutate: revalidate } =
|
||||
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||
const { data: routingRules, mutate: revalidateRules } = useSWR<RoutingRule[]>(
|
||||
'/api/v1/routingRule'
|
||||
);
|
||||
|
||||
const [editRadarrModal, setEditRadarrModal] = useState<{
|
||||
open: boolean;
|
||||
radarr: RadarrSettings | null;
|
||||
@@ -240,9 +232,10 @@ const SettingsServices = () => {
|
||||
type: 'radarr',
|
||||
serverId: null,
|
||||
});
|
||||
const [overrideRuleModal, setOverrideRuleModal] = useState<{
|
||||
const [routingRuleModal, setRoutingRuleModal] = useState<{
|
||||
open: boolean;
|
||||
rule: OverrideRule | null;
|
||||
rule: RoutingRule | null;
|
||||
prefillData?: Partial<RoutingRule>;
|
||||
}>({
|
||||
open: false,
|
||||
rule: null,
|
||||
@@ -256,6 +249,73 @@ const SettingsServices = () => {
|
||||
revalidateRadarr();
|
||||
revalidateSonarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
revalidateRules();
|
||||
};
|
||||
|
||||
const handleRadarrSave = async (savedInstance: RadarrSettings) => {
|
||||
setEditRadarrModal({ open: false, radarr: null });
|
||||
revalidateRadarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
|
||||
if (!savedInstance.isDefault) return;
|
||||
|
||||
const rules = (await revalidateRules()) ?? [];
|
||||
|
||||
const existingDefault = rules.find(
|
||||
(r) =>
|
||||
r.serviceType === 'radarr' &&
|
||||
r.is4k === savedInstance.is4k &&
|
||||
r.isFallback
|
||||
);
|
||||
|
||||
setRoutingRuleModal({
|
||||
open: true,
|
||||
rule: existingDefault
|
||||
? { ...existingDefault, targetServiceId: savedInstance.id }
|
||||
: null,
|
||||
prefillData: existingDefault
|
||||
? undefined
|
||||
: {
|
||||
name: `${savedInstance.name} Default Route`,
|
||||
serviceType: 'radarr',
|
||||
is4k: savedInstance.is4k,
|
||||
targetServiceId: savedInstance.id,
|
||||
isFallback: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSonarrSave = async (savedInstance: SonarrSettings) => {
|
||||
setEditSonarrModal({ open: false, sonarr: null });
|
||||
revalidateSonarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
|
||||
if (!savedInstance.isDefault) return;
|
||||
|
||||
const rules = (await revalidateRules()) ?? [];
|
||||
|
||||
const existingDefault = rules.find(
|
||||
(r) =>
|
||||
r.serviceType === 'sonarr' &&
|
||||
r.is4k === savedInstance.is4k &&
|
||||
r.isFallback
|
||||
);
|
||||
|
||||
setRoutingRuleModal({
|
||||
open: true,
|
||||
rule: existingDefault
|
||||
? { ...existingDefault, targetServiceId: savedInstance.id }
|
||||
: null,
|
||||
prefillData: existingDefault
|
||||
? undefined
|
||||
: {
|
||||
name: `${savedInstance.name} Default Route`,
|
||||
serviceType: 'sonarr',
|
||||
is4k: savedInstance.is4k,
|
||||
targetServiceId: savedInstance.id,
|
||||
isFallback: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -266,44 +326,22 @@ const SettingsServices = () => {
|
||||
intl.formatMessage(globalMessages.settings),
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.radarrsettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.serviceSettingsDescription, {
|
||||
serverType: 'Radarr',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editRadarrModal.open && (
|
||||
<RadarrModal
|
||||
radarr={editRadarrModal.radarr}
|
||||
onClose={() => {
|
||||
if (!overrideRuleModal.open)
|
||||
setEditRadarrModal({ open: false, radarr: null });
|
||||
}}
|
||||
onSave={() => {
|
||||
revalidateRadarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
setEditRadarrModal({ open: false, radarr: null });
|
||||
}}
|
||||
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
|
||||
onSave={handleRadarrSave}
|
||||
/>
|
||||
)}
|
||||
{editSonarrModal.open && (
|
||||
<SonarrModal
|
||||
sonarr={editSonarrModal.sonarr}
|
||||
onClose={() => {
|
||||
if (!overrideRuleModal.open)
|
||||
setEditSonarrModal({ open: false, sonarr: null });
|
||||
}}
|
||||
onSave={() => {
|
||||
revalidateSonarr();
|
||||
mutate('/api/v1/settings/public');
|
||||
setEditSonarrModal({ open: false, sonarr: null });
|
||||
}}
|
||||
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
|
||||
onSave={handleSonarrSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={deleteServerModal.open}
|
||||
@@ -333,226 +371,128 @@ const SettingsServices = () => {
|
||||
{intl.formatMessage(messages.deleteserverconfirm)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="section">
|
||||
{!radarrData && !radarrError && <LoadingSpinner />}
|
||||
{radarrData && !radarrError && (
|
||||
<>
|
||||
{radarrData.length > 0 &&
|
||||
(!radarrData.some((radarr) => radarr.isDefault) ? (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noDefaultServer, {
|
||||
serverType: 'Radarr',
|
||||
mediaType: intl.formatMessage(messages.mediaTypeMovie),
|
||||
})}
|
||||
/>
|
||||
) : !radarrData.some(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
) ? (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noDefaultNon4kServer, {
|
||||
serverType: 'Radarr',
|
||||
strong: (msg: React.ReactNode) => (
|
||||
<strong className="font-semibold text-white">
|
||||
{msg}
|
||||
</strong>
|
||||
),
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
radarrData.some((radarr) => radarr.is4k) &&
|
||||
!radarrData.some(
|
||||
(radarr) => radarr.isDefault && radarr.is4k
|
||||
) && (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noDefault4kServer, {
|
||||
serverType: 'Radarr',
|
||||
mediaType: intl.formatMessage(messages.mediaTypeMovie),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
hostname={radarr.hostname}
|
||||
port={radarr.port}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault}
|
||||
is4k={radarr.is4k}
|
||||
externalUrl={radarr.externalUrl}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className="mb-3 mt-3"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addradarr)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 mt-10">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.sonarrsettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.serviceSettingsDescription, {
|
||||
serverType: 'Sonarr',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
{!sonarrData && !sonarrError && <LoadingSpinner />}
|
||||
{sonarrData && !sonarrError && (
|
||||
<>
|
||||
{sonarrData.length > 0 &&
|
||||
(!sonarrData.some((sonarr) => sonarr.isDefault) ? (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noDefaultServer, {
|
||||
serverType: 'Sonarr',
|
||||
mediaType: intl.formatMessage(messages.mediaTypeSeries),
|
||||
})}
|
||||
/>
|
||||
) : !sonarrData.some(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
) ? (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noDefaultNon4kServer, {
|
||||
serverType: 'Sonarr',
|
||||
strong: (msg: React.ReactNode) => (
|
||||
<strong className="font-semibold text-white">
|
||||
{msg}
|
||||
</strong>
|
||||
),
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
sonarrData.some((sonarr) => sonarr.is4k) &&
|
||||
!sonarrData.some(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k
|
||||
) && (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noDefault4kServer, {
|
||||
serverType: 'Sonarr',
|
||||
mediaType: intl.formatMessage(messages.mediaTypeSeries),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
hostname={sonarr.hostname}
|
||||
port={sonarr.port}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault={sonarr.isDefault}
|
||||
is4k={sonarr.is4k}
|
||||
externalUrl={sonarr.externalUrl}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addsonarr)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 mt-10">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.overrideRules)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.overrideRulesDescription, {
|
||||
serverType: 'Sonarr',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{rules && radarrData && sonarrData && (
|
||||
<OverrideRuleTiles
|
||||
rules={rules}
|
||||
radarrServices={radarrData}
|
||||
sonarrServices={sonarrData}
|
||||
setOverrideRuleModal={setOverrideRuleModal}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
)}
|
||||
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
disabled={!radarrData?.length && !sonarrData?.length}
|
||||
onClick={() =>
|
||||
setOverrideRuleModal({
|
||||
open: true,
|
||||
rule: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{overrideRuleModal.open && radarrData && sonarrData && (
|
||||
<OverrideRuleModal
|
||||
rule={overrideRuleModal.rule}
|
||||
|
||||
{routingRuleModal.open && radarrData && sonarrData && (
|
||||
<RoutingRuleModal
|
||||
rule={routingRuleModal.rule}
|
||||
onClose={() => {
|
||||
setOverrideRuleModal({
|
||||
open: false,
|
||||
rule: null,
|
||||
});
|
||||
revalidate();
|
||||
setRoutingRuleModal({ open: false, rule: null });
|
||||
revalidateRules();
|
||||
}}
|
||||
radarrServices={radarrData}
|
||||
sonarrServices={sonarrData}
|
||||
prefillData={routingRuleModal.prefillData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">{intl.formatMessage(messages.instances)}</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.instancesDescription)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
{(!radarrData && !radarrError) || (!sonarrData && !sonarrError) ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{radarrData?.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
isDefault={radarr.isDefault}
|
||||
hostname={radarr.hostname}
|
||||
port={radarr.port}
|
||||
ruleCount={
|
||||
routingRules?.filter(
|
||||
(r) =>
|
||||
r.serviceType === 'radarr' &&
|
||||
r.targetServiceId === radarr.id
|
||||
).length ?? 0
|
||||
}
|
||||
isSSL={radarr.useSsl}
|
||||
is4k={radarr.is4k}
|
||||
externalUrl={radarr.externalUrl}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sonarrData?.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
isDefault={sonarr.isDefault}
|
||||
hostname={sonarr.hostname}
|
||||
port={sonarr.port}
|
||||
ruleCount={
|
||||
routingRules?.filter(
|
||||
(r) =>
|
||||
r.serviceType === 'sonarr' &&
|
||||
r.targetServiceId === sonarr.id
|
||||
).length ?? 0
|
||||
}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
is4k={sonarr.is4k}
|
||||
externalUrl={sonarr.externalUrl}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addRadarr)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addSonarr)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
{radarrData && sonarrData && routingRules && (
|
||||
<RoutingRuleList
|
||||
rules={routingRules}
|
||||
radarrServices={radarrData}
|
||||
sonarrServices={sonarrData}
|
||||
onAddRule={(prefillData) =>
|
||||
setRoutingRuleModal({ open: true, rule: null, prefillData })
|
||||
}
|
||||
onEditRule={(rule) => setRoutingRuleModal({ open: true, rule })}
|
||||
revalidate={revalidateRules}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,16 +10,9 @@ import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { OnChangeValue } from 'react-select';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type OptionType = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const messages = defineMessages('components.Settings.SonarrModal', {
|
||||
createsonarr: 'Add New Sonarr Server',
|
||||
create4ksonarr: 'Add New 4K Sonarr Server',
|
||||
@@ -29,9 +22,6 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
validationProfileRequired: 'You must select a quality profile',
|
||||
validationLanguageProfileRequired: 'You must select a language profile',
|
||||
toastSonarrTestSuccess: 'Sonarr connection established successfully!',
|
||||
toastSonarrTestFailure: 'Failed to connect to Sonarr.',
|
||||
add: 'Add Server',
|
||||
@@ -43,27 +33,8 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
||||
ssl: 'Use SSL',
|
||||
apiKey: 'API Key',
|
||||
baseUrl: 'URL Base',
|
||||
qualityprofile: 'Quality Profile',
|
||||
languageprofile: 'Language Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
seriesType: 'Series Type',
|
||||
animeSeriesType: 'Anime Series Type',
|
||||
animequalityprofile: 'Anime Quality Profile',
|
||||
animelanguageprofile: 'Anime Language Profile',
|
||||
animerootfolder: 'Anime Root Folder',
|
||||
seasonfolders: 'Season Folders',
|
||||
server4k: '4K Server',
|
||||
selectQualityProfile: 'Select quality profile',
|
||||
selectRootFolder: 'Select root folder',
|
||||
selectLanguageProfile: 'Select language profile',
|
||||
loadingprofiles: 'Loading quality profiles…',
|
||||
testFirstQualityProfiles: 'Test connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test connection to load root folders',
|
||||
loadinglanguageprofiles: 'Loading language profiles…',
|
||||
testFirstLanguageProfiles: 'Test connection to load language profiles',
|
||||
loadingTags: 'Loading tags…',
|
||||
testFirstTags: 'Test connection to load tags',
|
||||
seasonfolders: 'Season Folders',
|
||||
syncEnabled: 'Enable Scan',
|
||||
externalUrl: 'External URL',
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
@@ -74,16 +45,12 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
||||
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
|
||||
tags: 'Tags',
|
||||
animeTags: 'Anime Tags',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
});
|
||||
|
||||
interface SonarrModalProps {
|
||||
sonarr: SonarrSettings | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSave: (savedInstance: SonarrSettings) => Promise<void>;
|
||||
}
|
||||
|
||||
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
@@ -112,17 +79,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
apiKey: Yup.string().required(
|
||||
intl.formatMessage(messages.validationApiKeyRequired)
|
||||
),
|
||||
rootFolder: Yup.string().required(
|
||||
intl.formatMessage(messages.validationRootFolderRequired)
|
||||
),
|
||||
activeProfileId: Yup.string().required(
|
||||
intl.formatMessage(messages.validationProfileRequired)
|
||||
),
|
||||
activeLanguageProfileId: testResponse.languageProfiles
|
||||
? Yup.number().required(
|
||||
intl.formatMessage(messages.validationLanguageProfileRequired)
|
||||
)
|
||||
: Yup.number(),
|
||||
externalUrl: Yup.string()
|
||||
.test(
|
||||
'valid-url',
|
||||
@@ -230,16 +186,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
ssl: sonarr?.useSsl ?? false,
|
||||
apiKey: sonarr?.apiKey,
|
||||
baseUrl: sonarr?.baseUrl,
|
||||
activeProfileId: sonarr?.activeProfileId,
|
||||
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
|
||||
rootFolder: sonarr?.activeDirectory,
|
||||
seriesType: sonarr?.seriesType,
|
||||
animeSeriesType: sonarr?.animeSeriesType,
|
||||
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
|
||||
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
|
||||
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
|
||||
tags: sonarr?.tags ?? [],
|
||||
animeTags: sonarr?.animeTags ?? [],
|
||||
isDefault: sonarr?.isDefault ?? false,
|
||||
is4k: sonarr?.is4k ?? false,
|
||||
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
|
||||
@@ -251,13 +197,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
validationSchema={SonarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const profileName = testResponse.profiles.find(
|
||||
(profile) => profile.id === Number(values.activeProfileId)
|
||||
)?.name;
|
||||
const animeProfileName = testResponse.profiles.find(
|
||||
(profile) => profile.id === Number(values.activeAnimeProfileId)
|
||||
)?.name;
|
||||
|
||||
const submission = {
|
||||
name: values.name,
|
||||
hostname: values.hostname,
|
||||
@@ -265,44 +204,32 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
apiKey: values.apiKey,
|
||||
useSsl: values.ssl,
|
||||
baseUrl: values.baseUrl,
|
||||
activeProfileId: Number(values.activeProfileId),
|
||||
activeLanguageProfileId: values.activeLanguageProfileId
|
||||
? Number(values.activeLanguageProfileId)
|
||||
: undefined,
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
seriesType: values.seriesType,
|
||||
animeSeriesType: values.animeSeriesType,
|
||||
activeAnimeProfileId: values.activeAnimeProfileId
|
||||
? Number(values.activeAnimeProfileId)
|
||||
: undefined,
|
||||
activeAnimeLanguageProfileId: values.activeAnimeLanguageProfileId
|
||||
? Number(values.activeAnimeLanguageProfileId)
|
||||
: undefined,
|
||||
activeAnimeProfileName: animeProfileName ?? undefined,
|
||||
activeAnimeDirectory: values.activeAnimeRootFolder,
|
||||
tags: values.tags,
|
||||
animeTags: values.animeTags,
|
||||
is4k: values.is4k,
|
||||
isDefault: values.isDefault,
|
||||
is4k: values.is4k,
|
||||
enableSeasonFolders: values.enableSeasonFolders,
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: !values.enableSearch,
|
||||
tagRequests: values.tagRequests,
|
||||
};
|
||||
let savedInstance: SonarrSettings;
|
||||
if (!sonarr) {
|
||||
await axios.post('/api/v1/settings/sonarr', submission);
|
||||
const response = await axios.post<SonarrSettings>(
|
||||
'/api/v1/settings/sonarr',
|
||||
submission
|
||||
);
|
||||
savedInstance = response.data;
|
||||
} else {
|
||||
await axios.put(
|
||||
const response = await axios.put<SonarrSettings>(
|
||||
`/api/v1/settings/sonarr/${sonarr.id}`,
|
||||
submission
|
||||
);
|
||||
savedInstance = response.data;
|
||||
}
|
||||
|
||||
onSave();
|
||||
await onSave(savedInstance);
|
||||
} catch (e) {
|
||||
// set error here
|
||||
// TODO: handle error
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -534,444 +461,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="seriesType" className="text-label">
|
||||
{intl.formatMessage(messages.seriesType)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="seriesType"
|
||||
name="seriesType"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="daily">Daily</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{errors.seriesType && touched.seriesType && (
|
||||
<div className="error">{errors.seriesType}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeProfileId" className="text-label">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectQualityProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
<option
|
||||
key={`loaded-profile-${profile.id}`}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeProfileId &&
|
||||
touched.activeProfileId &&
|
||||
typeof errors.activeProfileId === 'string' && (
|
||||
<div className="error">{errors.activeProfileId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="rootFolder" className="text-label">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstRootFolders
|
||||
)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
<option
|
||||
key={`loaded-profile-${folder.id}`}
|
||||
value={folder.path}
|
||||
>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.rootFolder &&
|
||||
touched.rootFolder &&
|
||||
typeof errors.rootFolder === 'string' && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{testResponse.languageProfiles && (
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="activeLanguageProfileId"
|
||||
className="text-label"
|
||||
>
|
||||
{intl.formatMessage(messages.languageprofile)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeLanguageProfileId"
|
||||
name="activeLanguageProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(
|
||||
messages.loadinglanguageprofiles
|
||||
)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstLanguageProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectLanguageProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.languageProfiles.length > 0 &&
|
||||
testResponse.languageProfiles.map((language) => (
|
||||
<option
|
||||
key={`loaded-profile-${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeLanguageProfileId &&
|
||||
touched.activeLanguageProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeLanguageProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Select<OptionType, true>
|
||||
options={
|
||||
isValidated
|
||||
? testResponse.tags.map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
placeholder={
|
||||
!isValidated
|
||||
? intl.formatMessage(messages.testFirstTags)
|
||||
: isTesting
|
||||
? intl.formatMessage(messages.loadingTags)
|
||||
: intl.formatMessage(messages.selecttags)
|
||||
}
|
||||
isLoading={isTesting}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
isTesting
|
||||
? []
|
||||
: (values.tags
|
||||
.map((tagId) => {
|
||||
const foundTag = testResponse.tags.find(
|
||||
(tag) => tag.id === tagId
|
||||
);
|
||||
|
||||
if (!foundTag) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: foundTag.id,
|
||||
label: foundTag.label,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(option) => option !== undefined
|
||||
) as OptionType[])
|
||||
}
|
||||
onChange={(value: OnChangeValue<OptionType, true>) => {
|
||||
setFieldValue(
|
||||
'tags',
|
||||
value.map((option) => option.value)
|
||||
);
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.notagoptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="animeSeriesType" className="text-label">
|
||||
{intl.formatMessage(messages.animeSeriesType)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="animeSeriesType"
|
||||
name="animeSeriesType"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="anime">Anime</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{errors.animeSeriesType && touched.animeSeriesType && (
|
||||
<div className="error">{errors.animeSeriesType}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeAnimeProfileId" className="text-label">
|
||||
{intl.formatMessage(messages.animequalityprofile)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeProfileId"
|
||||
name="activeAnimeProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectQualityProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
<option
|
||||
key={`loaded-profile-${profile.id}`}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeAnimeProfileId &&
|
||||
touched.activeAnimeProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeAnimeProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeAnimeRootFolder" className="text-label">
|
||||
{intl.formatMessage(messages.animerootfolder)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeRootFolder"
|
||||
name="activeAnimeRootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstRootFolders
|
||||
)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
<option
|
||||
key={`loaded-profile-${folder.id}`}
|
||||
value={folder.path}
|
||||
>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeAnimeRootFolder &&
|
||||
touched.activeAnimeRootFolder && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{testResponse.languageProfiles && (
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="activeAnimeLanguageProfileId"
|
||||
className="text-label"
|
||||
>
|
||||
{intl.formatMessage(messages.animelanguageprofile)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeLanguageProfileId"
|
||||
name="activeAnimeLanguageProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(
|
||||
messages.loadinglanguageprofiles
|
||||
)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstLanguageProfiles
|
||||
)
|
||||
: intl.formatMessage(
|
||||
messages.selectLanguageProfile
|
||||
)}
|
||||
</option>
|
||||
{testResponse.languageProfiles.length > 0 &&
|
||||
testResponse.languageProfiles.map((language) => (
|
||||
<option
|
||||
key={`loaded-profile-${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeAnimeLanguageProfileId &&
|
||||
touched.activeAnimeLanguageProfileId && (
|
||||
<div className="error">
|
||||
{errors.activeAnimeLanguageProfileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.animeTags)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Select<OptionType, true>
|
||||
options={
|
||||
isValidated
|
||||
? testResponse.tags.map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
isMulti
|
||||
isDisabled={!isValidated}
|
||||
placeholder={
|
||||
!isValidated
|
||||
? intl.formatMessage(messages.testFirstTags)
|
||||
: isTesting
|
||||
? intl.formatMessage(messages.loadingTags)
|
||||
: intl.formatMessage(messages.selecttags)
|
||||
}
|
||||
isLoading={isTesting}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
isTesting
|
||||
? []
|
||||
: (values.animeTags
|
||||
.map((tagId) => {
|
||||
const foundTag = testResponse.tags.find(
|
||||
(tag) => tag.id === tagId
|
||||
);
|
||||
|
||||
if (!foundTag) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: foundTag.id,
|
||||
label: foundTag.label,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(option) => option !== undefined
|
||||
) as OptionType[])
|
||||
}
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'animeTags',
|
||||
value.map((option) => option.value)
|
||||
);
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.notagoptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="enableSeasonFolders"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.seasonfolders)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enableSeasonFolders"
|
||||
name="enableSeasonFolders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="externalUrl" className="text-label">
|
||||
{intl.formatMessage(messages.externalUrl)}
|
||||
@@ -992,6 +481,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="enableSeasonFolders"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.seasonfolders)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enableSeasonFolders"
|
||||
name="enableSeasonFolders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="syncEnabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.syncEnabled)}
|
||||
|
||||
@@ -769,40 +769,7 @@
|
||||
"components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
|
||||
"components.Settings.Notifications.webhookUrl": "Webhook URL",
|
||||
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
|
||||
"components.Settings.OverrideRuleModal.conditions": "Conditions",
|
||||
"components.Settings.OverrideRuleModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).",
|
||||
"components.Settings.OverrideRuleModal.create": "Create rule",
|
||||
"components.Settings.OverrideRuleModal.createrule": "New Override Rule",
|
||||
"components.Settings.OverrideRuleModal.editrule": "Edit Override Rule",
|
||||
"components.Settings.OverrideRuleModal.genres": "Genres",
|
||||
"components.Settings.OverrideRuleModal.keywords": "Keywords",
|
||||
"components.Settings.OverrideRuleModal.languages": "Languages",
|
||||
"components.Settings.OverrideRuleModal.notagoptions": "No tags.",
|
||||
"components.Settings.OverrideRuleModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.OverrideRuleModal.rootfolder": "Root Folder",
|
||||
"components.Settings.OverrideRuleModal.ruleCreated": "Override rule created successfully!",
|
||||
"components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!",
|
||||
"components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile",
|
||||
"components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder",
|
||||
"components.Settings.OverrideRuleModal.selectService": "Select service",
|
||||
"components.Settings.OverrideRuleModal.selecttags": "Select tags",
|
||||
"components.Settings.OverrideRuleModal.service": "Service",
|
||||
"components.Settings.OverrideRuleModal.serviceDescription": "Apply this rule to the selected service.",
|
||||
"components.Settings.OverrideRuleModal.settings": "Settings",
|
||||
"components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
|
||||
"components.Settings.OverrideRuleModal.tags": "Tags",
|
||||
"components.Settings.OverrideRuleModal.users": "Users",
|
||||
"components.Settings.OverrideRuleTile.conditions": "Conditions",
|
||||
"components.Settings.OverrideRuleTile.genre": "Genre",
|
||||
"components.Settings.OverrideRuleTile.keywords": "Keywords",
|
||||
"components.Settings.OverrideRuleTile.language": "Language",
|
||||
"components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile",
|
||||
"components.Settings.OverrideRuleTile.rootfolder": "Root Folder",
|
||||
"components.Settings.OverrideRuleTile.settings": "Settings",
|
||||
"components.Settings.OverrideRuleTile.tags": "Tags",
|
||||
"components.Settings.OverrideRuleTile.users": "Users",
|
||||
"components.Settings.RadarrModal.add": "Add Server",
|
||||
"components.Settings.RadarrModal.announced": "Announced",
|
||||
"components.Settings.RadarrModal.apiKey": "API Key",
|
||||
"components.Settings.RadarrModal.baseUrl": "URL Base",
|
||||
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
|
||||
@@ -814,30 +781,13 @@
|
||||
"components.Settings.RadarrModal.enableSearch": "Enable Automatic Search",
|
||||
"components.Settings.RadarrModal.externalUrl": "External URL",
|
||||
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
|
||||
"components.Settings.RadarrModal.inCinemas": "In Cinemas",
|
||||
"components.Settings.RadarrModal.loadingTags": "Loading tags…",
|
||||
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
||||
"components.Settings.RadarrModal.notagoptions": "No tags.",
|
||||
"components.Settings.RadarrModal.port": "Port",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.RadarrModal.released": "Released",
|
||||
"components.Settings.RadarrModal.rootfolder": "Root Folder",
|
||||
"components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability",
|
||||
"components.Settings.RadarrModal.selectQualityProfile": "Select quality profile",
|
||||
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
|
||||
"components.Settings.RadarrModal.selecttags": "Select tags",
|
||||
"components.Settings.RadarrModal.server4k": "4K Server",
|
||||
"components.Settings.RadarrModal.servername": "Server Name",
|
||||
"components.Settings.RadarrModal.ssl": "Use SSL",
|
||||
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
|
||||
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
|
||||
"components.Settings.RadarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
|
||||
"components.Settings.RadarrModal.tags": "Tags",
|
||||
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
|
||||
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
|
||||
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
|
||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
|
||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!",
|
||||
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||
@@ -846,11 +796,83 @@
|
||||
"components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "URL base must have a leading slash",
|
||||
"components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "URL base must not end in a trailing slash",
|
||||
"components.Settings.RadarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address",
|
||||
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability",
|
||||
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name",
|
||||
"components.Settings.RadarrModal.validationPortRequired": "You must provide a valid port number",
|
||||
"components.Settings.RadarrModal.validationProfileRequired": "You must select a quality profile",
|
||||
"components.Settings.RadarrModal.validationRootFolderRequired": "You must select a root folder",
|
||||
"components.Settings.RoutingRuleList.addAnimeRule": "Add Anime Rule",
|
||||
"components.Settings.RoutingRuleList.addRule": "Add Rule",
|
||||
"components.Settings.RoutingRuleList.all": "All",
|
||||
"components.Settings.RoutingRuleList.animeRuleSuggestion": "Want anime to use different settings? Add an anime routing rule.",
|
||||
"components.Settings.RoutingRuleList.deleteConfirm": "Are you sure you want to delete this routing rule?",
|
||||
"components.Settings.RoutingRuleList.deleteRule": "Delete Routing Rule",
|
||||
"components.Settings.RoutingRuleList.howRoutingWorks": "How routing works:",
|
||||
"components.Settings.RoutingRuleList.noFallbackWarning": "No fallback rule configured for {serviceType}. Requests that do not match any rule will fail.",
|
||||
"components.Settings.RoutingRuleList.radarr": "Radarr",
|
||||
"components.Settings.RoutingRuleList.routingExplainer": "When a request comes in, rules are checked from top to bottom. The first rule whose conditions all match will determine which instance and settings are used. Fallback rules (no conditions) catch everything that did not match above.",
|
||||
"components.Settings.RoutingRuleList.routingRules": "Routing Rules",
|
||||
"components.Settings.RoutingRuleList.routingRulesConditionLogic": "Conditions use AND logic between fields (all must match) and OR logic within a field (any value can match).",
|
||||
"components.Settings.RoutingRuleList.routingRulesDescription": "Rules are evaluated top-to-bottom. The first matching rule determines where the request is sent. Drag to reorder priority.",
|
||||
"components.Settings.RoutingRuleList.sonarr": "Sonarr",
|
||||
"components.Settings.RoutingRuleModal.announced": "Announced",
|
||||
"components.Settings.RoutingRuleModal.badge4k": "4K",
|
||||
"components.Settings.RoutingRuleModal.badgeDefault": "Default",
|
||||
"components.Settings.RoutingRuleModal.conditionalShouldNotBeDefault": "Conditional rules should target a non-default instance.",
|
||||
"components.Settings.RoutingRuleModal.conditions": "Conditions",
|
||||
"components.Settings.RoutingRuleModal.conditionsDescription": "All condition types must match (AND). Within each type, any value can match (OR). Leave all empty for a fallback rule.",
|
||||
"components.Settings.RoutingRuleModal.create": "Create Rule",
|
||||
"components.Settings.RoutingRuleModal.createRule": "New Routing Rule",
|
||||
"components.Settings.RoutingRuleModal.editRule": "Edit Routing Rule",
|
||||
"components.Settings.RoutingRuleModal.fallbackMustBe4k": "This fallback is for 4K requests, so it must target a 4K instance.",
|
||||
"components.Settings.RoutingRuleModal.fallbackMustBeDefault": "Fallback rules must target a default instance.",
|
||||
"components.Settings.RoutingRuleModal.firstInstanceSetup": "First instance setup!",
|
||||
"components.Settings.RoutingRuleModal.firstInstanceSetup4k": "First 4K instance setup!",
|
||||
"components.Settings.RoutingRuleModal.firstInstanceSetupBody": "We’re creating a fallback rule that catches all {mediaType} requests. You can customize defaults below or save to use instance defaults.",
|
||||
"components.Settings.RoutingRuleModal.genres": "Genres",
|
||||
"components.Settings.RoutingRuleModal.inCinemas": "In Cinemas",
|
||||
"components.Settings.RoutingRuleModal.keywords": "Keywords",
|
||||
"components.Settings.RoutingRuleModal.languages": "Languages",
|
||||
"components.Settings.RoutingRuleModal.minimumAvailability": "Minimum Availability",
|
||||
"components.Settings.RoutingRuleModal.noTagOptions": "No tags.",
|
||||
"components.Settings.RoutingRuleModal.nonFallbackNeedsCondition": "Non-fallback rules must have at least one condition.",
|
||||
"components.Settings.RoutingRuleModal.qualityProfile": "Quality Profile",
|
||||
"components.Settings.RoutingRuleModal.released": "Released",
|
||||
"components.Settings.RoutingRuleModal.rootFolder": "Root Folder",
|
||||
"components.Settings.RoutingRuleModal.ruleCreated": "Routing rule created successfully!",
|
||||
"components.Settings.RoutingRuleModal.ruleName": "Rule Name",
|
||||
"components.Settings.RoutingRuleModal.ruleNamePlaceholder": "e.g. Anime Content, Japanese Dramas",
|
||||
"components.Settings.RoutingRuleModal.ruleUpdated": "Routing rule updated successfully!",
|
||||
"components.Settings.RoutingRuleModal.selectInstance": "Select instance",
|
||||
"components.Settings.RoutingRuleModal.selectQualityProfile": "Select quality profile",
|
||||
"components.Settings.RoutingRuleModal.selectRootFolder": "Select root folder",
|
||||
"components.Settings.RoutingRuleModal.selectTags": "Select tags",
|
||||
"components.Settings.RoutingRuleModal.seriesType": "Series Type",
|
||||
"components.Settings.RoutingRuleModal.serviceType": "Service Type",
|
||||
"components.Settings.RoutingRuleModal.tags": "Tags",
|
||||
"components.Settings.RoutingRuleModal.target": "Target Settings",
|
||||
"components.Settings.RoutingRuleModal.targetDescription": "Override settings for the target instance. Leave empty to use instance defaults.",
|
||||
"components.Settings.RoutingRuleModal.targetInstance": "Target Instance",
|
||||
"components.Settings.RoutingRuleModal.users": "Users",
|
||||
"components.Settings.RoutingRuleModal.validationMinimumAvailabilityRequired": "You must select a minimum availability",
|
||||
"components.Settings.RoutingRuleModal.validationNameRequired": "You must provide a rule name",
|
||||
"components.Settings.RoutingRuleModal.validationProfileRequired": "You must select a quality profile",
|
||||
"components.Settings.RoutingRuleModal.validationRootFolderRequired": "You must select a root folder",
|
||||
"components.Settings.RoutingRuleModal.validationTargetRequired": "You must select a target instance",
|
||||
"components.Settings.RoutingRuleRow.conditions": "Conditions",
|
||||
"components.Settings.RoutingRuleRow.fallback": "Fallback",
|
||||
"components.Settings.RoutingRuleRow.genres": "Genres",
|
||||
"components.Settings.RoutingRuleRow.instance": "Instance",
|
||||
"components.Settings.RoutingRuleRow.instanceDefaults": "Uses instance defaults",
|
||||
"components.Settings.RoutingRuleRow.keywords": "Keywords",
|
||||
"components.Settings.RoutingRuleRow.languages": "Languages",
|
||||
"components.Settings.RoutingRuleRow.matchesAll": "Matches all requests",
|
||||
"components.Settings.RoutingRuleRow.minimumAvailability": "Minimum Availability",
|
||||
"components.Settings.RoutingRuleRow.qualityProfile": "Quality Profile",
|
||||
"components.Settings.RoutingRuleRow.radarr": "Radarr",
|
||||
"components.Settings.RoutingRuleRow.rootFolder": "Root Folder",
|
||||
"components.Settings.RoutingRuleRow.routeTo": "Route To",
|
||||
"components.Settings.RoutingRuleRow.seriesType": "Series Type",
|
||||
"components.Settings.RoutingRuleRow.sonarr": "Sonarr",
|
||||
"components.Settings.RoutingRuleRow.tags": "Tags",
|
||||
"components.Settings.RoutingRuleRow.users": "Users",
|
||||
"components.Settings.SettingsAbout.Releases.currentversion": "Current",
|
||||
"components.Settings.SettingsAbout.Releases.latestversion": "Latest",
|
||||
"components.Settings.SettingsAbout.Releases.releasedataMissing": "Release data is currently unavailable.",
|
||||
@@ -1048,11 +1070,6 @@
|
||||
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
|
||||
"components.Settings.SettingsUsers.users": "Users",
|
||||
"components.Settings.SonarrModal.add": "Add Server",
|
||||
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
|
||||
"components.Settings.SonarrModal.animeTags": "Anime Tags",
|
||||
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
|
||||
"components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile",
|
||||
"components.Settings.SonarrModal.animerootfolder": "Anime Root Folder",
|
||||
"components.Settings.SonarrModal.apiKey": "API Key",
|
||||
"components.Settings.SonarrModal.baseUrl": "URL Base",
|
||||
"components.Settings.SonarrModal.create4ksonarr": "Add New 4K Sonarr Server",
|
||||
@@ -1064,32 +1081,14 @@
|
||||
"components.Settings.SonarrModal.enableSearch": "Enable Automatic Search",
|
||||
"components.Settings.SonarrModal.externalUrl": "External URL",
|
||||
"components.Settings.SonarrModal.hostname": "Hostname or IP Address",
|
||||
"components.Settings.SonarrModal.languageprofile": "Language Profile",
|
||||
"components.Settings.SonarrModal.loadingTags": "Loading tags…",
|
||||
"components.Settings.SonarrModal.loadinglanguageprofiles": "Loading language profiles…",
|
||||
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.SonarrModal.notagoptions": "No tags.",
|
||||
"components.Settings.SonarrModal.port": "Port",
|
||||
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||
"components.Settings.SonarrModal.seasonfolders": "Season Folders",
|
||||
"components.Settings.SonarrModal.selectLanguageProfile": "Select language profile",
|
||||
"components.Settings.SonarrModal.selectQualityProfile": "Select quality profile",
|
||||
"components.Settings.SonarrModal.selectRootFolder": "Select root folder",
|
||||
"components.Settings.SonarrModal.selecttags": "Select tags",
|
||||
"components.Settings.SonarrModal.seriesType": "Series Type",
|
||||
"components.Settings.SonarrModal.server4k": "4K Server",
|
||||
"components.Settings.SonarrModal.servername": "Server Name",
|
||||
"components.Settings.SonarrModal.ssl": "Use SSL",
|
||||
"components.Settings.SonarrModal.syncEnabled": "Enable Scan",
|
||||
"components.Settings.SonarrModal.tagRequests": "Tag Requests",
|
||||
"components.Settings.SonarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
|
||||
"components.Settings.SonarrModal.tags": "Tags",
|
||||
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
|
||||
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
|
||||
"components.Settings.SonarrModal.testFirstRootFolders": "Test connection to load root folders",
|
||||
"components.Settings.SonarrModal.testFirstTags": "Test connection to load tags",
|
||||
"components.Settings.SonarrModal.toastSonarrTestFailure": "Failed to connect to Sonarr.",
|
||||
"components.Settings.SonarrModal.toastSonarrTestSuccess": "Sonarr connection established successfully!",
|
||||
"components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||
@@ -1098,16 +1097,12 @@
|
||||
"components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "Base URL must have a leading slash",
|
||||
"components.Settings.SonarrModal.validationBaseUrlTrailingSlash": "Base URL must not end in a trailing slash",
|
||||
"components.Settings.SonarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address",
|
||||
"components.Settings.SonarrModal.validationLanguageProfileRequired": "You must select a language profile",
|
||||
"components.Settings.SonarrModal.validationNameRequired": "You must provide a server name",
|
||||
"components.Settings.SonarrModal.validationPortRequired": "You must provide a valid port number",
|
||||
"components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile",
|
||||
"components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder",
|
||||
"components.Settings.activeProfile": "Active Profile",
|
||||
"components.Settings.addradarr": "Add Radarr Server",
|
||||
"components.Settings.addInstance": "Add Instance",
|
||||
"components.Settings.addRadarr": "Add Radarr",
|
||||
"components.Settings.addSonarr": "Add Sonarr",
|
||||
"components.Settings.address": "Address",
|
||||
"components.Settings.addrule": "New Override Rule",
|
||||
"components.Settings.addsonarr": "Add Sonarr Server",
|
||||
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
||||
"components.Settings.allChosenProvidersAreOperational": "All chosen metadata providers are operational",
|
||||
"components.Settings.animeMetadataProvider": "Anime metadata provider",
|
||||
@@ -1137,6 +1132,8 @@
|
||||
"components.Settings.general": "General",
|
||||
"components.Settings.hostname": "Hostname or IP Address",
|
||||
"components.Settings.importBlocklistedTagsTip": "Import blocklisted tag configuration",
|
||||
"components.Settings.instances": "Instances",
|
||||
"components.Settings.instancesDescription": "Configure your Sonarr and Radarr server connections below. Routing rules determine which instance handles each request.",
|
||||
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
|
||||
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||
"components.Settings.is4k": "4K",
|
||||
@@ -1157,8 +1154,6 @@
|
||||
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Seerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
|
||||
"components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Seerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Seerr, a one-time full manual library scan is recommended!",
|
||||
"components.Settings.manualscanJellyfin": "Manual Library Scan",
|
||||
"components.Settings.mediaTypeMovie": "movie",
|
||||
"components.Settings.mediaTypeSeries": "series",
|
||||
"components.Settings.menuAbout": "About",
|
||||
"components.Settings.menuGeneralSettings": "General",
|
||||
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
|
||||
@@ -1175,9 +1170,7 @@
|
||||
"components.Settings.metadataSettings": "Settings for metadata provider",
|
||||
"components.Settings.metadataSettingsSaved": "Metadata provider settings saved",
|
||||
"components.Settings.no": "No",
|
||||
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
|
||||
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
|
||||
"components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.",
|
||||
"components.Settings.noRules": "No routing rules configured",
|
||||
"components.Settings.noSpecialCharacters": "Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.",
|
||||
"components.Settings.nooptions": "No results.",
|
||||
"components.Settings.notTested": "Not Tested",
|
||||
@@ -1186,8 +1179,6 @@
|
||||
"components.Settings.notificationsettings": "Notification Settings",
|
||||
"components.Settings.notrunning": "Not Running",
|
||||
"components.Settings.operational": "Operational",
|
||||
"components.Settings.overrideRules": "Override Rules",
|
||||
"components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.",
|
||||
"components.Settings.plex": "Plex",
|
||||
"components.Settings.plexlibraries": "Plex Libraries",
|
||||
"components.Settings.plexlibrariesDescription": "The libraries Seerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.",
|
||||
@@ -1195,8 +1186,9 @@
|
||||
"components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Seerr scans your Plex libraries to determine content availability.",
|
||||
"components.Settings.port": "Port",
|
||||
"components.Settings.providerStatus": "Metadata Provider Status",
|
||||
"components.Settings.radarrsettings": "Radarr Settings",
|
||||
"components.Settings.restartrequiredTooltip": "Seerr must be restarted for changes to this setting to take effect",
|
||||
"components.Settings.routingRules": "Routing Rules",
|
||||
"components.Settings.ruleCount": "{count} routing {count, plural, one {rule} other {rules}}",
|
||||
"components.Settings.save": "Save Changes",
|
||||
"components.Settings.saving": "Saving…",
|
||||
"components.Settings.scan": "Sync Libraries",
|
||||
@@ -1211,11 +1203,9 @@
|
||||
"components.Settings.serverpresetLoad": "Press the button to load available servers",
|
||||
"components.Settings.serverpresetManualMessage": "Manual configuration",
|
||||
"components.Settings.serverpresetRefreshing": "Retrieving servers…",
|
||||
"components.Settings.serviceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.",
|
||||
"components.Settings.services": "Services",
|
||||
"components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to fetch the list of available servers.",
|
||||
"components.Settings.settings": "Settings",
|
||||
"components.Settings.sonarrsettings": "Sonarr Settings",
|
||||
"components.Settings.ssl": "SSL",
|
||||
"components.Settings.startscan": "Start Scan",
|
||||
"components.Settings.starttyping": "Starting typing to search.",
|
||||
|
||||
Reference in New Issue
Block a user