diff --git a/.dockerignore b/.dockerignore
index 76aae0c402..17211b25eb 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,6 +8,7 @@
.gitconfig
.github
.gitignore
+.pnpm-store
.husky
.next
.prettierignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d0437b5e90..93443e34af 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,24 +35,25 @@ jobs:
NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - parallel:
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: 'package.json'
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: 'package.json'
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -93,12 +94,12 @@ jobs:
container: node:22.22.2-alpine3.23@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Get pnpm store directory
shell: sh
@@ -106,7 +107,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -118,14 +119,15 @@ jobs:
CI: true
run: pnpm install
- - name: Lint
- run: pnpm lint
+ - parallel:
+ - name: Lint
+ run: pnpm lint
- - name: Formatting
- run: pnpm format:check
+ - name: Formatting
+ run: pnpm format:check
- - name: Build
- run: pnpm build
+ - name: Build
+ run: pnpm build
unit-test:
name: Unit Tests
@@ -136,12 +138,12 @@ jobs:
checks: write
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Get pnpm store directory
shell: sh
@@ -149,7 +151,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -167,7 +169,7 @@ jobs:
run: pnpm test
- name: Publish test report
- uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
+ uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
if: success() || failure() # always run even if the previous step fails
with:
report_paths: 'report.xml'
@@ -186,20 +188,21 @@ jobs:
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Commit timestamp
id: ts
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
-
- name: Warm cache (no push) — ${{ matrix.platform }}
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
@@ -222,34 +225,35 @@ jobs:
packages: write
id-token: write
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Commit timestamp
- id: ts
- run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Log in to Docker Hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Commit timestamp
+ id: ts
+ run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
+
- name: Extract metadata
id: meta
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
${{ env.DOCKER_HUB }}
@@ -261,7 +265,7 @@ jobs:
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
- name: Build & Push (multi-arch, single tag)
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 9e0f15b49c..8f1d8f8aee 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -36,21 +36,22 @@ jobs:
matrix:
language: [actions, javascript]
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- - name: Initialize CodeQL
- uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
- with:
- languages: ${{ matrix.language }}
- queries: +security-and-quality
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+ with:
+ languages: ${{ matrix.language }}
+ queries: +security-and-quality
- name: Autobuild
- uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: '/language:${{ matrix.language }}'
diff --git a/.github/workflows/conflict_labeler.yml b/.github/workflows/conflict_labeler.yml
index 61e5d29a8d..220caa2196 100644
--- a/.github/workflows/conflict_labeler.yml
+++ b/.github/workflows/conflict_labeler.yml
@@ -28,7 +28,7 @@ jobs:
pull-requests: write
steps:
- name: Apply label
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
+ uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml
index 35ef124c9f..eabb687dac 100644
--- a/.github/workflows/create-tag.yml
+++ b/.github/workflows/create-tag.yml
@@ -22,16 +22,17 @@ jobs:
outputs:
tag_version: ${{ steps.git-cliff.outputs.tag_version }}
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
- - name: Install git-cliff
- uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2.75.23
- with:
- tool: git-cliff
+ - name: Install git-cliff
+ uses: taiki-e/install-action@742a3317eac7bd62f91cd888b4eead5e784ba833 # v2.87.1
+ with:
+ tool: git-cliff
- name: Get tag version
id: git-cliff
@@ -55,25 +56,26 @@ jobs:
TAG_VERSION: ${{ needs.determine-tag-version.outputs.tag_version }}
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ssh-key: '${{ secrets.COMMIT_KEY }}'
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - parallel:
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.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: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.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: 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
diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml
index e86bd917f5..a95f1aeedd 100644
--- a/.github/workflows/cypress.yml
+++ b/.github/workflows/cypress.yml
@@ -10,7 +10,7 @@ on:
- 'cypress.config.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- - 'next.config.js'
+ - 'next.config.ts'
- 'tsconfig.json'
- '.github/workflows/cypress.yml'
push:
@@ -20,7 +20,7 @@ on:
- 'cypress.config.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- - 'next.config.js'
+ - 'next.config.ts'
- 'tsconfig.json'
- '.github/workflows/cypress.yml'
@@ -37,24 +37,24 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+ - parallel:
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: package.json
+ package-manager-cache: false
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: package.json
- package-manager-cache: false
-
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup cypress cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -67,7 +67,7 @@ jobs:
run: pnpm exec cypress install
- name: Cypress run
- uses: cypress-io/github-action@c495c3ddffba403ba11be95fffb67e25203b3799 # v7.1.10
+ uses: cypress-io/github-action@09090944bd8aaa2a517cefb6e38bf1aae336b42b # v7.4.3
with:
install: false
build: pnpm cypress:build
diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml
index 842fbc5b66..5b46ef6125 100644
--- a/.github/workflows/detect-duplicate.yml
+++ b/.github/workflows/detect-duplicate.yml
@@ -8,7 +8,6 @@ on:
permissions: {}
env:
- EMBEDDING_MODEL: ${{ vars.EMBEDDING_MODEL }}
GROQ_MODEL: ${{ vars.GROQ_MODEL }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
@@ -23,22 +22,23 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - parallel:
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: 'package.json'
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: 'package.json'
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -51,14 +51,25 @@ jobs:
CI: true
run: pnpm install --frozen-lockfile
- - name: Download issue index
- uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
+ - name: Get latest successful run id
+ id: get-latest-run-id
+ run: |
+ RUN_ID=$(gh api \
+ /repos/${{ github.repository }}/actions/workflows/rebuild-issue-index.yml/runs \
+ --jq '[.workflow_runs[] | select(.status=="completed" and .conclusion=="success")][0].id // empty')
+ echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Download artifact
+ if: steps.get-latest-run-id.outputs.run_id != ''
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: issue-index
- workflow: rebuild-issue-index.yml
path: bin/duplicate-detector
- search_artifacts: true
- if_no_artifact_found: warn
+ run-id: ${{ steps.get-latest-run-id.outputs.run_id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build index if missing
working-directory: bin/duplicate-detector
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 32c85a4b2b..8363a48095 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -24,19 +24,19 @@ jobs:
name: Build Docusaurus
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
+ - parallel:
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: package.json
+ package-manager-cache: false
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: package.json
- package-manager-cache: false
-
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Get pnpm store directory
shell: sh
@@ -44,7 +44,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -58,7 +58,7 @@ jobs:
- name: Build website
working-directory: gen-docs
- run: pnpm gen-api-docs all && pnpm build
+ run: pnpm gen-api-docs && pnpm build
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b6 # v4.0.0
diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml
index 8c988c878d..724c7487a7 100644
--- a/.github/workflows/docs-link-check.yml
+++ b/.github/workflows/docs-link-check.yml
@@ -36,13 +36,13 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Run Lychee link checker
- uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0
+ uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
fail: false
args: >-
diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml
index e7c9a599dc..84937a8056 100644
--- a/.github/workflows/helm.yml
+++ b/.github/workflows/helm.yml
@@ -27,24 +27,25 @@ jobs:
outputs:
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Install helm
- uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5
-
- - name: Install Oras
- uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Install helm
+ uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
+
+ - name: Install Oras
+ uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Package helm charts
run: |
@@ -92,33 +93,34 @@ jobs:
needs: [package-helm-chart]
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Install helm
- uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5
-
- - name: Install Oras
- uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2
-
- - name: Install Cosign
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
-
- - name: Downloads artifacts
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: artifacts
- path: .cr-release-packages/
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Install helm
+ uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
+
+ - name: Install Oras
+ uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2
+
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
+ - name: Downloads artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: artifacts
+ path: .cr-release-packages/
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Push charts to GHCR
env:
@@ -150,27 +152,28 @@ jobs:
permissions:
contents: read
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Install Cosign
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
-
- - name: Downloads artifacts
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: artifacts
- path: .cr-release-packages/
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
+ - name: Downloads artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: artifacts
+ path: .cr-release-packages/
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify signatures for each chart tag
run: |
diff --git a/.github/workflows/lint-helm-charts.yml b/.github/workflows/lint-helm-charts.yml
index 283d1e09f8..a428a3abae 100644
--- a/.github/workflows/lint-helm-charts.yml
+++ b/.github/workflows/lint-helm-charts.yml
@@ -27,29 +27,31 @@ jobs:
permissions:
contents: read
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Set up Helm
- uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5
-
- - name: Set up chart-testing
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
-
- - name: Ensure documentation is updated
- uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c
-
- - name: Run chart-testing (list-changed)
- id: list-changed
- run: |
- changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }})
- if [[ -n "$changed" ]]; then
- echo "changed=true" >> "$GITHUB_OUTPUT"
- echo "$changed"
- fi
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Helm
+ uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
+
+ - name: Set up chart-testing
+ uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
+
+ - parallel:
+ - name: Ensure documentation is updated
+ uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c
+
+ - name: Run chart-testing (list-changed)
+ id: list-changed
+ run: |
+ changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }})
+ if [[ -n "$changed" ]]; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ echo "$changed"
+ fi
- name: Run chart-testing
if: steps.list-changed.outputs.changed == 'true'
diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
index 57353cdd90..01446ac568 100644
--- a/.github/workflows/pr-validation.yml
+++ b/.github/workflows/pr-validation.yml
@@ -31,82 +31,83 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
- if: always() && steps.lint_pr_title.outputs.error_message != null
- env:
- ERROR_MESSAGE: ${{ steps.lint_pr_title.outputs.error_message }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const message = process.env.ERROR_MESSAGE;
- const prNumber = context.payload.pull_request.number;
+ - parallel:
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ if: always() && steps.lint_pr_title.outputs.error_message != null
+ env:
+ ERROR_MESSAGE: ${{ steps.lint_pr_title.outputs.error_message }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const message = process.env.ERROR_MESSAGE;
+ const prNumber = context.payload.pull_request.number;
- const body = [
- `### PR Title Validation Failed\n`,
- message,
- `\n---\n`,
- `PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).`,
- `*This check will re-run when you update your PR title.*`,
- ].join('\n');
+ const body = [
+ `### PR Title Validation Failed\n`,
+ message,
+ `\n---\n`,
+ `PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).`,
+ `*This check will re-run when you update your PR title.*`,
+ ].join('\n');
- const allComments = await github.paginate(
- github.rest.issues.listComments,
- {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- per_page: 100,
- }
- );
+ const allComments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ }
+ );
- const botComment = allComments.find(
- c => c.user.type === 'Bot' && c.body && c.body.includes('### PR Title Validation Failed')
- );
+ const botComment = allComments.find(
+ c => c.user.type === 'Bot' && c.body && c.body.includes('### PR Title Validation Failed')
+ );
- if (botComment) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: botComment.id,
- body,
- });
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- body,
- });
- }
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body,
+ });
+ }
- - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
- if: always() && steps.lint_pr_title.outputs.error_message == null
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const prNumber = context.payload.pull_request.number;
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ if: always() && steps.lint_pr_title.outputs.error_message == null
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const prNumber = context.payload.pull_request.number;
- const allComments = await github.paginate(
- github.rest.issues.listComments,
- {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- per_page: 100,
- }
- );
+ const allComments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ }
+ );
- const botComment = allComments.find(
- c => c.user.type === 'Bot' && c.body && c.body.includes('### PR Title Validation Failed')
- );
+ const botComment = allComments.find(
+ c => c.user.type === 'Bot' && c.body && c.body.includes('### PR Title Validation Failed')
+ );
- if (botComment) {
- await github.rest.issues.deleteComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: botComment.id,
- });
- }
+ if (botComment) {
+ await github.rest.issues.deleteComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ });
+ }
template-check:
name: Validate PR Template
@@ -117,22 +118,23 @@ jobs:
issues: write
pull-requests: write
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
+ - parallel:
+ - name: Skip bot PRs
+ id: bot-check
+ if: github.event.pull_request.user.type == 'Bot'
+ run: echo "skip=true" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: 'package.json'
package-manager-cache: false
- - name: Skip bot PRs
- id: bot-check
- if: github.event.pull_request.user.type == 'Bot'
- run: echo "skip=true" >> "$GITHUB_OUTPUT"
-
- name: Write PR body to file
if: steps.bot-check.outputs.skip != 'true'
env:
@@ -156,130 +158,131 @@ jobs:
} >> "$GITHUB_OUTPUT"
exit 0
- - name: Label and comment on failure
- if: steps.bot-check.outputs.skip != 'true' && steps.check.outputs.exit_code != '0'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
- env:
- ISSUES_JSON: ${{ steps.check.outputs.issues }}
- PR_AUTHOR: ${{ github.event.pull_request.user.login }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const issues = JSON.parse(process.env.ISSUES_JSON);
- const author = process.env.PR_AUTHOR;
- const prNumber = context.payload.pull_request.number;
- const LABEL = 'blocked:template';
+ - parallel:
+ - name: Label and comment on failure
+ if: steps.bot-check.outputs.skip != 'true' && steps.check.outputs.exit_code != '0'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ env:
+ ISSUES_JSON: ${{ steps.check.outputs.issues }}
+ PR_AUTHOR: ${{ github.event.pull_request.user.login }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const issues = JSON.parse(process.env.ISSUES_JSON);
+ const author = process.env.PR_AUTHOR;
+ const prNumber = context.payload.pull_request.number;
+ const LABEL = 'blocked:template';
- const issueList = issues.map(i => `- ${i}`).join('\n');
+ const issueList = issues.map(i => `- ${i}`).join('\n');
- const commentBody = [
- `Hey @${author}, thanks for submitting this PR! However, it looks like the PR template hasn't been fully filled out.\n`,
- `### Issues found:\n`,
- issueList,
- `\n---\n`,
- `**Please update your PR description to follow the [PR template](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/develop/.github/PULL_REQUEST_TEMPLATE.md).**`,
- `Incomplete or missing PR descriptions may indicate insufficient review of the changes, and PRs that do not follow the template **may be closed without review**.`,
- `See our [Contributing Guide](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/develop/CONTRIBUTING.md) for more details.\n`,
- `*This check will automatically re-run when you edit your PR description.*`,
- ].join('\n');
+ const commentBody = [
+ `Hey @${author}, thanks for submitting this PR! However, it looks like the PR template hasn't been fully filled out.\n`,
+ `### Issues found:\n`,
+ issueList,
+ `\n---\n`,
+ `**Please update your PR description to follow the [PR template](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/develop/.github/PULL_REQUEST_TEMPLATE.md).**`,
+ `Incomplete or missing PR descriptions may indicate insufficient review of the changes, and PRs that do not follow the template **may be closed without review**.`,
+ `See our [Contributing Guide](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/develop/CONTRIBUTING.md) for more details.\n`,
+ `*This check will automatically re-run when you edit your PR description.*`,
+ ].join('\n');
- const allComments = await github.paginate(
- github.rest.issues.listComments,
- {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- per_page: 100,
- }
- );
+ const allComments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ }
+ );
- const botComment = allComments.find(
- c => c.user.type === 'Bot' && c.body && c.body.includes('### Issues found:')
- );
+ const botComment = allComments.find(
+ c => c.user.type === 'Bot' && c.body && c.body.includes('### Issues found:')
+ );
- if (botComment) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: botComment.id,
- body: commentBody,
- });
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- body: commentBody,
- });
- }
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: commentBody,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body: commentBody,
+ });
+ }
- try {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- labels: [LABEL],
- });
- } catch (e) {
- try {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: LABEL,
- color: 'B60205',
- description: 'PR template not properly filled out',
- });
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- labels: [LABEL],
- });
- } catch (e2) {
- console.log('Could not create/add label:', e2.message);
- }
- }
+ try {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: [LABEL],
+ });
+ } catch (e) {
+ try {
+ await github.rest.issues.createLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: LABEL,
+ color: 'B60205',
+ description: 'PR template not properly filled out',
+ });
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: [LABEL],
+ });
+ } catch (e2) {
+ console.log('Could not create/add label:', e2.message);
+ }
+ }
- core.setFailed('PR template is not properly filled out.');
+ core.setFailed('PR template is not properly filled out.');
- - name: Remove label on success
- if: steps.bot-check.outputs.skip != 'true' && steps.check.outputs.exit_code == '0'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const prNumber = context.payload.pull_request.number;
- const LABEL = 'blocked:template';
+ - name: Remove label on success
+ if: steps.bot-check.outputs.skip != 'true' && steps.check.outputs.exit_code == '0'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const prNumber = context.payload.pull_request.number;
+ const LABEL = 'blocked:template';
- try {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- name: LABEL,
- });
- } catch (e) {
- console.log('Could not remove label', e.message);
- }
+ try {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ name: LABEL,
+ });
+ } catch (e) {
+ console.log('Could not remove label', e.message);
+ }
- const allComments = await github.paginate(
- github.rest.issues.listComments,
- {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- per_page: 100,
- }
- );
+ const allComments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ }
+ );
- const botComment = allComments.find(
- c => c.user.type === 'Bot' && c.body && c.body.includes('### Issues found:')
- );
+ const botComment = allComments.find(
+ c => c.user.type === 'Bot' && c.body && c.body.includes('### Issues found:')
+ );
- if (botComment) {
- await github.rest.issues.deleteComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: botComment.id,
- });
- }
+ if (botComment) {
+ await github.rest.issues.deleteComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ });
+ }
diff --git a/.github/workflows/preview-books.yml b/.github/workflows/preview-books.yml
new file mode 100644
index 0000000000..a01d1e4700
--- /dev/null
+++ b/.github/workflows/preview-books.yml
@@ -0,0 +1,115 @@
+---
+# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
+name: Seerr Books Preview
+
+on:
+ push:
+ tags:
+ - 'preview-books'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build (per-arch, native runners)
+ strategy:
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ platform: linux/amd64
+ arch: amd64
+ - runner: ubuntu-24.04-arm
+ platform: linux/arm64
+ arch: arm64
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ persist-credentials: false
+
+ - name: Commit timestamp
+ id: ts
+ run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Warm cache (no push) — ${{ matrix.platform }}
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: ${{ matrix.platform }}
+ push: false
+ build-args: |
+ COMMIT_TAG=${{ github.sha }}
+ BUILD_VERSION=preview-books
+ SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
+ cache-from: type=gha,scope=${{ matrix.platform }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+ provenance: false
+
+ publish:
+ name: Publish multi-arch image
+ needs: build
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ persist-credentials: false
+
+ - name: Commit timestamp
+ id: ts
+ run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: |
+ ghcr.io/${{ github.repository }}
+ tags: |
+ type=raw,value=preview-books
+ labels: |
+ org.opencontainers.image.version=preview-books
+ org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
+
+ - name: Build & Push (multi-arch, single tag)
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: true
+ build-args: |
+ COMMIT_TAG=${{ github.sha }}
+ BUILD_VERSION=preview-books
+ SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
+ labels: ${{ steps.meta.outputs.labels }}
+ tags: ${{ steps.meta.outputs.tags }}
+ cache-from: |
+ type=gha,scope=linux/amd64
+ type=gha,scope=linux/arm64
+ cache-to: type=gha,mode=max
+ provenance: false
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 16452b9873..f70c3332a5 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -32,30 +32,31 @@ jobs:
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+
+ - name: Derive preview version from tag
+ id: ver
+ shell: bash
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ VER="${TAG#preview-}"
+ VER="${VER#v}"
+ echo "version=${VER}" >> "$GITHUB_OUTPUT"
+ echo "Building preview version: ${VER}"
- name: Commit timestamp
id: ts
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
-
- - name: Derive preview version from tag
- id: ver
- shell: bash
- run: |
- TAG="${GITHUB_REF_NAME}"
- VER="${TAG#preview-}"
- VER="${VER#v}"
- echo "version=${VER}" >> "$GITHUB_OUTPUT"
- echo "Building preview version: ${VER}"
-
- name: Warm cache (no push) — ${{ matrix.platform }}
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
@@ -78,44 +79,44 @@ jobs:
packages: write
id-token: write
steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Commit timestamp
- id: ts
- run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
+ - parallel:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+
+ - name: Derive preview version from tag
+ id: ver
+ shell: bash
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ VER="${TAG#preview-}"
+ VER="${VER#v}"
+ echo "version=${VER}" >> "$GITHUB_OUTPUT"
+ echo "Publishing preview version: ${VER}"
- name: Log in to Docker Hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Derive preview version from tag
- id: ver
- shell: bash
- run: |
- TAG="${GITHUB_REF_NAME}"
- VER="${TAG#preview-}"
- VER="${VER#v}"
- echo "version=${VER}" >> "$GITHUB_OUTPUT"
- echo "Publishing preview version: ${VER}"
+ - name: Commit timestamp
+ id: ts
+ run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
${{ env.DOCKER_HUB }}
@@ -127,7 +128,7 @@ jobs:
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
- name: Build & Push (multi-arch, single tag)
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
diff --git a/.github/workflows/rebuild-issue-index.yml b/.github/workflows/rebuild-issue-index.yml
index dc39c1df76..a1aa0ba34d 100644
--- a/.github/workflows/rebuild-issue-index.yml
+++ b/.github/workflows/rebuild-issue-index.yml
@@ -3,14 +3,11 @@ name: Rebuild Issue Index
on:
schedule:
- - cron: "0 3 * * *"
+ - cron: '0 3 * * *'
workflow_dispatch:
permissions: {}
-env:
- EMBEDDING_MODEL: ${{ vars.EMBEDDING_MODEL }}
-
jobs:
build-index:
runs-on: ubuntu-24.04
@@ -21,22 +18,23 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - parallel:
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: 'package.json'
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: 'package.json'
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
diff --git a/.github/workflows/release-books.yml b/.github/workflows/release-books.yml
new file mode 100644
index 0000000000..2d74cdc806
--- /dev/null
+++ b/.github/workflows/release-books.yml
@@ -0,0 +1,161 @@
+---
+# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
+name: Seerr Books Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ changelog:
+ name: Generate changelog
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ outputs:
+ release_body: ${{ steps.git-cliff.outputs.content }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Generate changelog
+ id: git-cliff
+ uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0
+ with:
+ config: .github/cliff.toml
+ args: -vv --current
+ env:
+ OUTPUT: CHANGELOG.md
+ GITHUB_REPO: ${{ github.repository }}
+
+ create-draft-release:
+ name: Create draft release
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ needs: changelog
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - name: Draft Release
+ run: gh release create ${GITHUB_REF_NAME} -t "Release ${GITHUB_REF_NAME}" -n "${RELEASE_BODY}" --draft
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_BODY: ${{ needs.changelog.outputs.release_body }}
+
+ build:
+ name: Build (per-arch, native runners)
+ strategy:
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ platform: linux/amd64
+ arch: amd64
+ - runner: ubuntu-24.04-arm
+ platform: linux/arm64
+ arch: arm64
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - name: Commit timestamp
+ id: ts
+ run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
+
+ - name: Warm cache (no push) — ${{ matrix.platform }}
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: ${{ matrix.platform }}
+ push: false
+ build-args: |
+ COMMIT_TAG=${{ github.sha }}
+ BUILD_VERSION=${{ github.ref_name }}
+ SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
+ cache-from: type=gha,scope=${{ matrix.platform }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+ provenance: false
+
+ publish:
+ name: Publish multi-arch image
+ needs: build
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - name: Commit timestamp
+ id: ts
+ run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
+ with:
+ images: |
+ ghcr.io/${{ github.repository }}
+ # latest=auto also adds :latest for non-prerelease semver tags
+ flavor: |
+ latest=auto
+ tags: |
+ type=semver,pattern={{raw}}
+ type=semver,pattern=v{{major}}.{{minor}}
+ type=semver,pattern=v{{major}}
+ labels: |
+ org.opencontainers.image.version=${{ github.ref_name }}
+ org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
+
+ - name: Build & Push (multi-arch)
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: true
+ build-args: |
+ COMMIT_TAG=${{ github.sha }}
+ BUILD_VERSION=${{ github.ref_name }}
+ SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
+ labels: ${{ steps.meta.outputs.labels }}
+ tags: ${{ steps.meta.outputs.tags }}
+ cache-from: |
+ type=gha,scope=linux/amd64
+ type=gha,scope=linux/arm64
+ cache-to: type=gha,mode=max
+ provenance: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 574154322d..0000000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,308 +0,0 @@
----
-# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
-name: Seerr Release
-
-on:
- push:
- tags:
- - 'v*'
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-env:
- DOCKER_HUB: seerr/seerr
-
-jobs:
- changelog:
- name: Generate changelog
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- outputs:
- release_body: ${{ steps.git-cliff.outputs.content }}
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Generate changelog
- id: git-cliff
- uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0
- with:
- config: .github/cliff.toml
- args: -vv --current
- env:
- OUTPUT: CHANGELOG.md
- GITHUB_REPO: ${{ github.repository }}
-
- create-draft-release:
- name: Create draft release
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- needs: changelog
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Draft Release
- run: gh release create ${GITHUB_REF_NAME} -t "Release ${GITHUB_REF_NAME}" -n "${RELEASE_BODY}" --draft
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- RELEASE_BODY: ${{ needs.changelog.outputs.release_body }}
-
- build:
- name: Build (${{ matrix.arch }})
- strategy:
- matrix:
- include:
- - runner: ubuntu-24.04
- platform: linux/amd64
- arch: amd64
- - runner: ubuntu-24.04-arm
- platform: linux/arm64
- arch: arm64
- runs-on: ${{ matrix.runner }}
- env:
- VERSION: ${{ github.ref_name }}
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Commit timestamp
- id: ts
- run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
-
- - name: Warm cache [${{ matrix.platform }}]
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- with:
- context: .
- file: ./Dockerfile
- platforms: ${{ matrix.platform }}
- push: false
- build-args: |
- COMMIT_TAG=${{ github.sha }}
- BUILD_VERSION=${{ env.VERSION }}
- SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
- cache-from: type=gha,scope=${{ matrix.platform }}
- cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- provenance: false
-
- publish:
- name: Publish multi-arch manifests
- needs: build
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- packages: write
- outputs:
- image_digest: ${{ steps.digests.outputs.IMAGE_DIGEST }}
- env:
- VERSION: ${{ github.ref_name }}
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Commit timestamp
- id: ts
- run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
-
- - name: Log in to Docker Hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract metadata
- id: meta
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
- with:
- images: |
- ${{ env.DOCKER_HUB }}
- ghcr.io/${{ github.repository }}
- tags: |
- type=semver,pattern={{raw}}
- type=semver,pattern=v{{major}}.{{minor}}
- type=semver,pattern=v{{major}}
- labels: |
- org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
-
- - name: Build & Push (multi-arch)
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- with:
- context: .
- file: ./Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- build-args: |
- COMMIT_TAG=${{ github.sha }}
- BUILD_VERSION=${{ env.VERSION }}
- SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
- labels: ${{ steps.meta.outputs.labels }}
- tags: ${{ steps.meta.outputs.tags }}
- cache-from: |
- type=gha,scope=linux/amd64
- type=gha,scope=linux/arm64
- cache-to: type=gha,mode=max
- provenance: false
-
- - name: Resolve manifest digest
- id: digests
- run: |
- DIGEST=$(docker buildx imagetools inspect "${{ env.DOCKER_HUB }}:${{ env.VERSION }}" --format '{{json .Manifest.Digest}}' | tr -d '"')
- echo "IMAGE_DIGEST=$DIGEST" >> $GITHUB_OUTPUT
-
- - name: Also tag :latest (non-pre-release only)
- shell: bash
- if: ${{ !contains(env.VERSION, '-') }}
- run: |
- docker buildx imagetools create \
- -t ${{ env.DOCKER_HUB }}:latest \
- ${{ env.DOCKER_HUB }}:${{ env.VERSION }}
-
- docker buildx imagetools create \
- -t ghcr.io/${{ github.repository }}:latest \
- ghcr.io/${{ github.repository }}:${{ env.VERSION }}
-
- sign:
- name: Sign images and create SBOM attestations
- needs: publish
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- id-token: write
- packages: write
- env:
- VERSION: ${{ github.ref_name }}
- COSIGN_YES: 'true'
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Install Cosign
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
-
- - name: Install Trivy
- uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.6
-
- - name: Log in to Docker Hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Sign images
- run: |
- cosign sign --recursive "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}"
- cosign sign --recursive "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}"
-
- - name: Generate SBOMs
- run: |
- trivy image --format cyclonedx --output seerr-ghcr-image-${{ env.VERSION }}.sbom \
- "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}"
-
- trivy image --format cyclonedx --output seerr-dockerhub-image-${{ env.VERSION }}.sbom \
- "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}"
-
- - name: Attest SBOMs
- run: |
- cosign attest \
- --type cyclonedx \
- --predicate seerr-ghcr-image-${{ env.VERSION }}.sbom \
- "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}"
-
- cosign attest \
- --type cyclonedx \
- --predicate seerr-dockerhub-image-${{ env.VERSION }}.sbom \
- "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}"
-
- - name: Upload SBOMs
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: sboms-${{ env.VERSION }}
- path: '*.sbom'
- if-no-files-found: error
- retention-days: 1
-
- verify:
- name: Verify signatures and attestations
- needs: [publish, sign]
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- env:
- VERSION: ${{ github.ref_name }}
- steps:
- - name: Install Cosign
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
-
- - name: Verify signatures
- run: |
- cosign verify "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" \
- --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
- --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
-
- cosign verify "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" \
- --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
- --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
-
- # - name: Verify attestations
- # run: |
- # cosign verify-attestation "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" \
- # --type cyclonedx \
- # --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
- # --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null
-
- # cosign verify-attestation "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" \
- # --type cyclonedx \
- # --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
- # --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null
-
- publish-release:
- name: Publish release
- needs: [create-draft-release, verify]
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- env:
- VERSION: ${{ github.ref_name }}
- steps:
- - name: Publish release
- run: gh release edit "${{ env.VERSION }}" --draft=false --repo "${{ github.repository }}"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/renovate-helm-custom-hooks.yml b/.github/workflows/renovate-helm-custom-hooks.yml
index b3cde18da4..534a467b14 100644
--- a/.github/workflows/renovate-helm-custom-hooks.yml
+++ b/.github/workflows/renovate-helm-custom-hooks.yml
@@ -24,20 +24,21 @@ jobs:
pull-requests: write
if: github.actor == 'renovate[bot]'
steps:
- - name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- id: app-token
- with:
- app-id: 2138788
- private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
-
- - name: Set up chart-testing
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
+ - parallel:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ id: app-token
+ with:
+ client-id: ${{ secrets.APP_SEERR_HELM_CLIENT_ID }}
+ private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
+
+ - name: Set up chart-testing
+ uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
- name: Run chart-testing (list-changed)
id: list-changed
diff --git a/.github/workflows/seerr-labeller.yml b/.github/workflows/seerr-labeller.yml
index 7a8330555a..e7e8f7b8d1 100644
--- a/.github/workflows/seerr-labeller.yml
+++ b/.github/workflows/seerr-labeller.yml
@@ -27,39 +27,40 @@ jobs:
NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
steps:
- - name: Label added, comment and close pull request
- if: github.event.action == 'labeled' && github.event.label.name == 'ai-generated'
- shell: bash
- env:
- BODY: >
- :wave: @${{ env.PR_AUTHOR }}, thank you for your contribution!
+ - parallel:
+ - name: Label added, comment and close pull request
+ if: github.event.action == 'labeled' && github.event.label.name == 'ai-generated'
+ shell: bash
+ env:
+ BODY: >
+ :wave: @${{ env.PR_AUTHOR }}, thank you for your contribution!
- However, this pull request has been closed because it appears to contain a significant amount of AI-generated code without sufficient human review or supervision.
+ However, this pull request has been closed because it appears to contain a significant amount of AI-generated code without sufficient human review or supervision.
- AI-generated code can often introduce subtle bugs, poor design patterns, or inconsistent styles that make long-term maintenance difficult and reduce overall code quality. For the sake of the project's future stability and readability, we require that all contributions meet our established coding standards and demonstrate clear developer oversight.
+ AI-generated code can often introduce subtle bugs, poor design patterns, or inconsistent styles that make long-term maintenance difficult and reduce overall code quality. For the sake of the project's future stability and readability, we require that all contributions meet our established coding standards and demonstrate clear developer oversight.
- This pull request is also too large for effective human review. Please discuss with us on how to break down these changes into smaller, more focused PRs to ensure a thorough and efficient review process.
- If you'd like to revise and resubmit your changes with careful review and cleanup, we'd be happy to take another look.
- run: |
- retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
- retry gh pr comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
- retry gh pr close "$NUMBER" -R "$GH_REPO" || true
- gh pr lock "$NUMBER" -R "$GH_REPO" -r "spam" || true
+ Please read our [AI Assistance Notice](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice) for what we expect from AI-assisted contributions.
+ If you'd like to revise and resubmit your changes with genuine review, testing, and an accurate disclosure, we'd be happy to take another look.
+ run: |
+ retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
+ retry gh pr comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
+ retry gh pr close "$NUMBER" -R "$GH_REPO" || true
+ gh pr lock "$NUMBER" -R "$GH_REPO" -r "spam" || true
- - name: Label removed, reopen and unlock pull request
- if: github.event.action == 'unlabeled' && github.event.label.name == 'ai-generated'
- shell: bash
- run: |
- retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
- retry gh pr reopen "$NUMBER" -R "$GH_REPO" || true
- gh pr unlock "$NUMBER" -R "$GH_REPO" || true
+ - name: Label removed, reopen and unlock pull request
+ if: github.event.action == 'unlabeled' && github.event.label.name == 'ai-generated'
+ shell: bash
+ run: |
+ retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
+ retry gh pr reopen "$NUMBER" -R "$GH_REPO" || true
+ gh pr unlock "$NUMBER" -R "$GH_REPO" || true
- - name: Remove AI-generated label on manual reopen
- if: github.event.action == 'reopened'
- shell: bash
- run: |
- gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "ai-generated" || true
- gh pr unlock "$NUMBER" -R "$GH_REPO" || true
+ - name: Remove AI-generated label on manual reopen
+ if: github.event.action == 'reopened'
+ shell: bash
+ run: |
+ gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "ai-generated" || true
+ gh pr unlock "$NUMBER" -R "$GH_REPO" || true
support:
if: >
@@ -78,34 +79,35 @@ jobs:
NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
steps:
- - name: Label added, comment and close issue
- if: github.event.action == 'labeled' && github.event.label.name == 'support'
- shell: bash
- env:
- BODY: >
- :wave: @${{ env.ISSUE_AUTHOR }}, we use the issue tracker exclusively
- for bug reports and feature requests. However, this issue appears
- to be a support request. Please use our support channels
- to get help with Seerr.
+ - parallel:
+ - name: Label added, comment and close issue
+ if: github.event.action == 'labeled' && github.event.label.name == 'support'
+ shell: bash
+ env:
+ BODY: >
+ :wave: @${{ env.ISSUE_AUTHOR }}, we use the issue tracker exclusively
+ for bug reports and feature requests. However, this issue appears
+ to be a support request. Please use our support channels
+ to get help with Seerr.
- - [Discord](https://discord.gg/seerr)
- run: |
- retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
- retry gh issue comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
- retry gh issue close "$NUMBER" -R "$GH_REPO" || true
- gh issue lock "$NUMBER" -R "$GH_REPO" -r "off_topic" || true
+ - [Discord](https://discord.gg/seerr)
+ run: |
+ retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
+ retry gh issue comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
+ retry gh issue close "$NUMBER" -R "$GH_REPO" || true
+ gh issue lock "$NUMBER" -R "$GH_REPO" -r "off_topic" || true
- - name: Label removed, reopen and unlock issue
- if: github.event.action == 'unlabeled' && github.event.label.name == 'support'
- shell: bash
- run: |
- retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
- retry gh issue reopen "$NUMBER" -R "$GH_REPO" || true
- gh issue unlock "$NUMBER" -R "$GH_REPO" || true
+ - name: Label removed, reopen and unlock issue
+ if: github.event.action == 'unlabeled' && github.event.label.name == 'support'
+ shell: bash
+ run: |
+ retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
+ retry gh issue reopen "$NUMBER" -R "$GH_REPO" || true
+ gh issue unlock "$NUMBER" -R "$GH_REPO" || true
- - name: Remove support label on manual reopen
- if: github.event.action == 'reopened'
- shell: bash
- run: |
- gh issue edit "$NUMBER" -R "$GH_REPO" --remove-label "support" || true
- gh issue unlock "$NUMBER" -R "$GH_REPO" || true
+ - name: Remove support label on manual reopen
+ if: github.event.action == 'reopened'
+ shell: bash
+ run: |
+ gh issue edit "$NUMBER" -R "$GH_REPO" --remove-label "support" || true
+ gh issue unlock "$NUMBER" -R "$GH_REPO" || true
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index e78886dd12..ce1b169990 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -21,7 +21,7 @@ jobs:
issues: write
pull-requests: write
steps:
- - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
+ - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
any-of-labels: "pending author's response"
exempt-issue-labels: 'confirmed'
diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml
index c4dc8bdf5e..d97c53d77c 100644
--- a/.github/workflows/test-docs.yml
+++ b/.github/workflows/test-docs.yml
@@ -24,19 +24,20 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- - name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: package.json
- package-manager-cache: false
+ - parallel:
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: package.json
+ package-manager-cache: false
- - name: Pnpm Setup
- uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+ - name: Pnpm Setup
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Get pnpm store directory
shell: sh
@@ -44,7 +45,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -59,4 +60,4 @@ jobs:
- name: Build website
run: |
cd gen-docs
- pnpm build
+ pnpm gen-api-docs && pnpm build
diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml
index 0b419a377c..ba8190076f 100644
--- a/.github/workflows/trivy-scan.yml
+++ b/.github/workflows/trivy-scan.yml
@@ -34,13 +34,13 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Cache Trivy DB
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .trivycache
key: trivy-${{ runner.os }}-${{ hashFiles('**/Dockerfile') }}
@@ -56,6 +56,6 @@ jobs:
ignore-unfixed: true
- name: Upload SARIF to code scanning
- uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy.sarif
diff --git a/.gitignore b/.gitignore
index b8ab627578..7a2221a325 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ lcov.info
# next.js
/.next/
/out/
+/next-env.d.ts
# production
/build
@@ -79,3 +80,14 @@ config/cache
# Docker compose
compose.override.yaml
+
+# Devenv
+.devenv*
+devenv.local.nix
+devenv.local.yaml
+
+# direnv
+.direnv
+
+# pre-commit
+.pre-commit-config.yaml
diff --git a/.husky/commit-msg b/.husky/commit-msg
index e2f1f07714..522117da14 100755
--- a/.husky/commit-msg
+++ b/.husky/commit-msg
@@ -1,4 +1 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
[ -n "$HUSKY_BYPASS" ] || npx commitlint --edit $1
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 36af219892..2312dc587f 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
npx lint-staged
diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg
index 17e2764cdf..2d9f99924c 100755
--- a/.husky/prepare-commit-msg
+++ b/.husky/prepare-commit-msg
@@ -1,4 +1,2 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
exec < /dev/tty && npx cz --hook || true
diff --git a/.prettierignore b/.prettierignore
index d6464bf14b..91fd263c98 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -7,6 +7,7 @@ pnpm-lock.yaml
cypress/config/settings.cypress.json
.github
.vscode
+next-env.d.ts
# assets
src/assets/
diff --git a/.prettierrc.js b/.prettierrc.js
index 035c9a84f4..5a46b64562 100644
--- a/.prettierrc.js
+++ b/.prettierrc.js
@@ -9,6 +9,12 @@ module.exports = {
rangeEnd: 0, // default: Infinity
},
},
+ {
+ files: 'next-env.d.ts',
+ options: {
+ rangeEnd: 0, // default: Infinity
+ },
+ },
{
files: 'gen-docs/pnpm-lock.yaml',
options: {
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..995e62b28b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1959 @@
+## [2.7.3](https://github.com/fallenbagel/jellyseerr/compare/v2.7.2...v2.7.3) (2025-08-14)
+
+
+### Bug Fixes
+
+* **api:** add missing user settings' api docs ([#1820](https://github.com/fallenbagel/jellyseerr/issues/1820)) ([e52c631](https://github.com/fallenbagel/jellyseerr/commit/e52c63164fcf0fa1d35b61e4a9dedfae92764bdd))
+* **api:** make username field nullable in UserSettings API schema ([#1835](https://github.com/fallenbagel/jellyseerr/issues/1835)) ([c86ee0d](https://github.com/fallenbagel/jellyseerr/commit/c86ee0ddb1b1e24c296a2935aa964e7e2fb2b905))
+* **api:** update Plex Watchlist URL ([#1847](https://github.com/fallenbagel/jellyseerr/issues/1847)) ([17d4f13](https://github.com/fallenbagel/jellyseerr/commit/17d4f13afe389a9d0edd6eaa9a0728380a80d892))
+* **blacklist:** handle invalid keywords gracefully ([#1815](https://github.com/fallenbagel/jellyseerr/issues/1815)) ([ca16864](https://github.com/fallenbagel/jellyseerr/commit/ca1686425bcd34b05ebd3aa0b52ae939d2becc9d))
+* **MediaRequestSubscriber:** use event manager to get fresh media state for MEDIA_AVAILABLE notifications ([#1825](https://github.com/fallenbagel/jellyseerr/issues/1825)) ([3292f11](https://github.com/fallenbagel/jellyseerr/commit/3292f113081cf83aa01d522c9d19c3b5ce0e281a))
+* **media:** update delete media file logic to include is4k parameter ([#1832](https://github.com/fallenbagel/jellyseerr/issues/1832)) ([e02ee24](https://github.com/fallenbagel/jellyseerr/commit/e02ee24f70bae47731ddf445057703ce273b42ef))
+* **proxy:** initialize image proxies after the proxy is set up ([#1794](https://github.com/fallenbagel/jellyseerr/issues/1794)) ([e98f31e](https://github.com/fallenbagel/jellyseerr/commit/e98f31e66cd2c9836a24169be0b3446d0923d9f9)), closes [#1787](https://github.com/fallenbagel/jellyseerr/issues/1787)
+
+## [2.7.2](https://github.com/fallenbagel/jellyseerr/compare/v2.7.1...v2.7.2) (2025-07-21)
+
+
+### Bug Fixes
+
+* **proxy:** modify the registration of the axios interceptors ([#1791](https://github.com/fallenbagel/jellyseerr/issues/1791)) ([75a7279](https://github.com/fallenbagel/jellyseerr/commit/75a7279ea24874548fece12bb5e7c97d78d088a9)), closes [#1787](https://github.com/fallenbagel/jellyseerr/issues/1787)
+
+## [2.7.1](https://github.com/fallenbagel/jellyseerr/compare/v2.7.0...v2.7.1) (2025-07-15)
+
+
+### Bug Fixes
+
+* allow setting IPv6 as an IP address in hostname field ([#1782](https://github.com/fallenbagel/jellyseerr/issues/1782)) ([844b1ab](https://github.com/fallenbagel/jellyseerr/commit/844b1abad9589c57ea6f56717212d9219b2aa954))
+* **gotify:** notifications blocked when priority set to 0 ([#1763](https://github.com/fallenbagel/jellyseerr/issues/1763)) ([8c43db2](https://github.com/fallenbagel/jellyseerr/commit/8c43db2abf3b504dbb789369c9a9ac92bb820722))
+* **proxy:** apply all proxy settings to Axios ([#1741](https://github.com/fallenbagel/jellyseerr/issues/1741)) ([b83367c](https://github.com/fallenbagel/jellyseerr/commit/b83367cbf2e0470cc1ad4eed8ec6eafaafafdbad))
+* remove LunaSea ([#1759](https://github.com/fallenbagel/jellyseerr/issues/1759)) ([510108f](https://github.com/fallenbagel/jellyseerr/commit/510108f9bbec9651a5d91e11ea411e688b5043fe)), closes [#1756](https://github.com/fallenbagel/jellyseerr/issues/1756)
+
+# [2.7.0](https://github.com/fallenbagel/jellyseerr/compare/v2.6.0...v2.7.0) (2025-06-20)
+
+
+### Bug Fixes
+
+* **blacklist:** hide items from MediaSliders when hideBlacklisted is enabled ([#1713](https://github.com/fallenbagel/jellyseerr/issues/1713)) ([d4a6cb2](https://github.com/fallenbagel/jellyseerr/commit/d4a6cb268a33d96c03f1f76c207b5597e4eae6e7))
+* correct typing issue ([#1715](https://github.com/fallenbagel/jellyseerr/issues/1715)) ([bb95c70](https://github.com/fallenbagel/jellyseerr/commit/bb95c7009faaf22103c1c8e84e3403823377ce0f))
+* **jellyfin:** use the same deviceId for admins ([#1710](https://github.com/fallenbagel/jellyseerr/issues/1710)) ([c7284f4](https://github.com/fallenbagel/jellyseerr/commit/c7284f473c43634b3a324f3b11a9a60990b3c0da))
+* **proxy:** apply http proxy settings to axios ([#1716](https://github.com/fallenbagel/jellyseerr/issues/1716)) ([7c969f4](https://github.com/fallenbagel/jellyseerr/commit/7c969f4235aa052234084c3cb951d485c6fff9cd))
+* redirect the 'Request' button to the right page ([#1711](https://github.com/fallenbagel/jellyseerr/issues/1711)) ([9cb7e14](https://github.com/fallenbagel/jellyseerr/commit/9cb7e1495ab2860cea614d10f6f7b62cf77b4def)), closes [#1588](https://github.com/fallenbagel/jellyseerr/issues/1588)
+* **settings:** add a tip for youtube URL setting ([#1714](https://github.com/fallenbagel/jellyseerr/issues/1714)) ([fb8677f](https://github.com/fallenbagel/jellyseerr/commit/fb8677f29cfe2a7f0e0c465a1a742be119517886))
+
+
+### Features
+
+* add force ipv4 first setting ([#1719](https://github.com/fallenbagel/jellyseerr/issues/1719)) ([0357d17](https://github.com/fallenbagel/jellyseerr/commit/0357d172058ceda7d49a0c18c13009e0031e034d))
+
+# [2.6.0](https://github.com/fallenbagel/jellyseerr/compare/v2.5.2...v2.6.0) (2025-06-09)
+
+
+### Bug Fixes
+
+* add missing cache for some tmdb images ([#1656](https://github.com/fallenbagel/jellyseerr/issues/1656)) ([8949ede](https://github.com/fallenbagel/jellyseerr/commit/8949edea7edcb64546f2c8b9363120102509a2a5))
+* **entity:** use TIMESTAMPTZ in Postgres and sort issue comments oldest-first ([#1654](https://github.com/fallenbagel/jellyseerr/issues/1654)) ([8da1c92](https://github.com/fallenbagel/jellyseerr/commit/8da1c9292391a39b8c08ed9f7cd7a2bb10217588)), closes [#1569](https://github.com/fallenbagel/jellyseerr/issues/1569) [#1568](https://github.com/fallenbagel/jellyseerr/issues/1568)
+* **filters:** display the right value when resetting the filter options ([#1695](https://github.com/fallenbagel/jellyseerr/issues/1695)) ([c0dd2e5](https://github.com/fallenbagel/jellyseerr/commit/c0dd2e5e27ad9927c52c54cd66bfb2b3cf7890d0)), closes [#1693](https://github.com/fallenbagel/jellyseerr/issues/1693)
+* **imagecache:** fix avatar cache folder creation ([#1581](https://github.com/fallenbagel/jellyseerr/issues/1581)) ([355b76d](https://github.com/fallenbagel/jellyseerr/commit/355b76de5cc029a76708bb754c6c4fd72ce99e3d)), closes [#1520](https://github.com/fallenbagel/jellyseerr/issues/1520)
+* **issuecomment:** fix issue display lists in IssueComment ([#1638](https://github.com/fallenbagel/jellyseerr/issues/1638)) ([515124b](https://github.com/fallenbagel/jellyseerr/commit/515124bab4b5e13759cb9497489dea828a6cef52)), closes [#1328](https://github.com/fallenbagel/jellyseerr/issues/1328) [#1328](https://github.com/fallenbagel/jellyseerr/issues/1328) [#1328](https://github.com/fallenbagel/jellyseerr/issues/1328)
+* **jellyfin:** clean up Jellyfin sessions on Jellyseerr logout ([#1651](https://github.com/fallenbagel/jellyseerr/issues/1651)) ([b27dbd7](https://github.com/fallenbagel/jellyseerr/commit/b27dbd7a155bed9490afdd9dc25ef8a7ca9311eb))
+* **mediarequests:** properly sort season numbers in media requests ([#1688](https://github.com/fallenbagel/jellyseerr/issues/1688)) ([6b8c0bd](https://github.com/fallenbagel/jellyseerr/commit/6b8c0bd8f3ac7a5c74bc0cb2c81edd2c22a7a618)), closes [#1336](https://github.com/fallenbagel/jellyseerr/issues/1336)
+* **pushover notificatons:** the sound setting will now be stored correctly ([#1630](https://github.com/fallenbagel/jellyseerr/issues/1630)) ([5fd65eb](https://github.com/fallenbagel/jellyseerr/commit/5fd65eb1ba4e8f383e8a11cb1004acc3bace26c0)), closes [#1614](https://github.com/fallenbagel/jellyseerr/issues/1614)
+* **requestlist:** remove unnecessary semicolon ([#1647](https://github.com/fallenbagel/jellyseerr/issues/1647)) ([6b9aedb](https://github.com/fallenbagel/jellyseerr/commit/6b9aedb97062e6b551cc7e023f699b8b26f730f0))
+* **ui:** correct seasons badge order ([#1648](https://github.com/fallenbagel/jellyseerr/issues/1648)) ([123894b](https://github.com/fallenbagel/jellyseerr/commit/123894b475fae421b416781587432ecc4e50acc2))
+* **ui:** make person media type filter consistent on mobile ([#1669](https://github.com/fallenbagel/jellyseerr/issues/1669)) ([24e1e94](https://github.com/fallenbagel/jellyseerr/commit/24e1e94747c9a070bc84a64ace47166efe52f7f1))
+* **url validation:** correct URL validation for empty fields ([#1657](https://github.com/fallenbagel/jellyseerr/issues/1657)) ([d226dbb](https://github.com/fallenbagel/jellyseerr/commit/d226dbb9b4279c3d649fb0c72656d35ba065491c))
+* **usediscover hook:** detect end of pagination when totalSize is a multiple of pageSize ([#1649](https://github.com/fallenbagel/jellyseerr/issues/1649)) ([45f2540](https://github.com/fallenbagel/jellyseerr/commit/45f25408c62f87e4a2b73e270644fb2f1f79a290)), closes [#1623](https://github.com/fallenbagel/jellyseerr/issues/1623)
+* **usersettings:** exclude current user when checking for existing email ([#1689](https://github.com/fallenbagel/jellyseerr/issues/1689)) ([ea7e68f](https://github.com/fallenbagel/jellyseerr/commit/ea7e68fc99bbc783277891b3408f07b7a70765be))
+
+
+### Features
+
+* add caching for TVDB images ([#1655](https://github.com/fallenbagel/jellyseerr/issues/1655)) ([e69649d](https://github.com/fallenbagel/jellyseerr/commit/e69649d71d19d23cafac363792a7e89c897bcad0))
+* add content certification/age-rating filter ([#1418](https://github.com/fallenbagel/jellyseerr/issues/1418)) ([149d79e](https://github.com/fallenbagel/jellyseerr/commit/149d79e5404cae48217806079b0ac0a34fbaeb35)), closes [#501](https://github.com/fallenbagel/jellyseerr/issues/501) [#501](https://github.com/fallenbagel/jellyseerr/issues/501)
+* allow changing YouTube host for trailers ([#643](https://github.com/fallenbagel/jellyseerr/issues/643)) ([d01f9a0](https://github.com/fallenbagel/jellyseerr/commit/d01f9a058042657ae1ce39537cdf0029ef878c8e))
+* **blacklist:** Automatically add media with blacklisted tags to the blacklist ([#1306](https://github.com/fallenbagel/jellyseerr/issues/1306)) ([4a5ac3c](https://github.com/fallenbagel/jellyseerr/commit/4a5ac3cc42fd924e8c83d64e5b01de44bed6b8ab))
+* **blacklist:** hide blacklisted items from discover pages for admins ([#1601](https://github.com/fallenbagel/jellyseerr/issues/1601)) ([185167a](https://github.com/fallenbagel/jellyseerr/commit/185167a0a75eb710a8696b58f7bc0a3f5939cee8))
+* **discord.ts:** adds a link to the pending approval discord notification ([#436](https://github.com/fallenbagel/jellyseerr/issues/436)) ([14ee52e](https://github.com/fallenbagel/jellyseerr/commit/14ee52e93e7e6b2c1c5ec6cf05abcac3c0d8163a))
+* filter by media type on PersonDetails ([#1566](https://github.com/fallenbagel/jellyseerr/issues/1566)) ([a19dcaf](https://github.com/fallenbagel/jellyseerr/commit/a19dcaf5e5c5cc4072a7e4733bbbf149a352915b)), closes [#1513](https://github.com/fallenbagel/jellyseerr/issues/1513) [#1513](https://github.com/fallenbagel/jellyseerr/issues/1513)
+* **gotify:** added priority input for gotify ([#1410](https://github.com/fallenbagel/jellyseerr/issues/1410)) ([21400ce](https://github.com/fallenbagel/jellyseerr/commit/21400cecdc1b964023087bce479bb7d141049080))
+* **issuecomment:** fix translation issue ([#1635](https://github.com/fallenbagel/jellyseerr/issues/1635)) ([8a42fe1](https://github.com/fallenbagel/jellyseerr/commit/8a42fe16b5ebca80e76d98fb05006cf41f5a953a)), closes [#1604](https://github.com/fallenbagel/jellyseerr/issues/1604)
+* make chart probes configurable ([#1574](https://github.com/fallenbagel/jellyseerr/issues/1574)) ([c3b8574](https://github.com/fallenbagel/jellyseerr/commit/c3b8574515de4f5a2c13f36b3a00708d0858966e))
+* now uses markdown linebreaks instead of relying purely on newlines ([#1514](https://github.com/fallenbagel/jellyseerr/issues/1514)) ([7d36dc1](https://github.com/fallenbagel/jellyseerr/commit/7d36dc182b462c12a31833ee08e74255d7964c24))
+* **ntfy:** add native ntfy notification support ([#1599](https://github.com/fallenbagel/jellyseerr/issues/1599)) ([fc4db7f](https://github.com/fallenbagel/jellyseerr/commit/fc4db7fa002acbb31f1fd8d20da019291d8096d8)), closes [#499](https://github.com/fallenbagel/jellyseerr/issues/499)
+* **requestlist:** add requests list media type filtering ([#1511](https://github.com/fallenbagel/jellyseerr/issues/1511)) ([e8f1edc](https://github.com/fallenbagel/jellyseerr/commit/e8f1edc0621387921dca73a8c9ad1d15f6b3a847))
+
+## [2.5.2](https://github.com/fallenbagel/jellyseerr/compare/v2.5.1...v2.5.2) (2025-04-03)
+
+
+### Bug Fixes
+
+* **auth:** Bitwarden autofill fix on local/Jellyfin login (2) ([#1487](https://github.com/fallenbagel/jellyseerr/issues/1487)) ([85bbc85](https://github.com/fallenbagel/jellyseerr/commit/85bbc857141d38bcf5244078437ed6a3318bba67))
+* **avatar:** fix avatar cache busting by using avatarVersion ([#1537](https://github.com/fallenbagel/jellyseerr/issues/1537)) ([29034b3](https://github.com/fallenbagel/jellyseerr/commit/29034b350d35ebaed52556448e46436aeb644e77))
+* correct "Remove from *arr" button ([#1544](https://github.com/fallenbagel/jellyseerr/issues/1544)) ([8dc1d81](https://github.com/fallenbagel/jellyseerr/commit/8dc1d8196c67bee0e772941445c294f0ca367961)), closes [#1476](https://github.com/fallenbagel/jellyseerr/issues/1476) [#1494](https://github.com/fallenbagel/jellyseerr/issues/1494)
+* **helm:** apply annotations to pvc ([#1489](https://github.com/fallenbagel/jellyseerr/issues/1489)) ([e5ab847](https://github.com/fallenbagel/jellyseerr/commit/e5ab847547564869c3aa6443b1e22208c09a7810))
+* **jellyfin:** ensure deviceID is never empty ([#1538](https://github.com/fallenbagel/jellyseerr/issues/1538)) ([7438042](https://github.com/fallenbagel/jellyseerr/commit/7438042757cb0e81534cf9f766d84dd3ff57fd84))
+* **job:** handle media removal for 4k on the same server ([#1543](https://github.com/fallenbagel/jellyseerr/issues/1543)) ([63dc27d](https://github.com/fallenbagel/jellyseerr/commit/63dc27d400ecc80a18442fc42dd417cc03c3f9e1))
+* **job:** rename Plex Sync to Jellyfin Sync ([#1549](https://github.com/fallenbagel/jellyseerr/issues/1549)) ([2f6be95](https://github.com/fallenbagel/jellyseerr/commit/2f6be955b51e8920c8954413286577e6fea4aee2))
+* **migrations:** add missing Postgres migration and fix SQLite migration ([#1532](https://github.com/fallenbagel/jellyseerr/issues/1532)) ([0b0b76e](https://github.com/fallenbagel/jellyseerr/commit/0b0b76e58c583fc7c31d7821e7825e32065f7944)), closes [#1466](https://github.com/fallenbagel/jellyseerr/issues/1466)
+* **ui:** handle import-from-plex response as array ([#1510](https://github.com/fallenbagel/jellyseerr/issues/1510)) ([4cd02ba](https://github.com/fallenbagel/jellyseerr/commit/4cd02babbace98c01bcef153a50d34cb36dd1d4b))
+* **ui:** resolve discover language dropdown overlap ([#1497](https://github.com/fallenbagel/jellyseerr/issues/1497)) ([f5b3a52](https://github.com/fallenbagel/jellyseerr/commit/f5b3a526cb9b12c19e5ff6a79240e3d85685ff9b)), closes [#1475](https://github.com/fallenbagel/jellyseerr/issues/1475)
+
+## [2.5.1](https://github.com/fallenbagel/jellyseerr/compare/v2.5.0...v2.5.1) (2025-03-17)
+
+
+### Bug Fixes
+
+* **auth:** Bitwarden autofill fix on local/Jellyfin login ([#1459](https://github.com/fallenbagel/jellyseerr/issues/1459)) ([b085e12](https://github.com/fallenbagel/jellyseerr/commit/b085e12ff9df9f57d71ca1fe27fefa8319229a2a))
+* **blacklist:** add back the blacklist button on TitleCard for Plex ([#1463](https://github.com/fallenbagel/jellyseerr/issues/1463)) ([4d1163c](https://github.com/fallenbagel/jellyseerr/commit/4d1163c34384efa59fe9b5401c5bd42d7f0435fc)), closes [#1398](https://github.com/fallenbagel/jellyseerr/issues/1398)
+* check if the file still exists in the service before deleting ([#1476](https://github.com/fallenbagel/jellyseerr/issues/1476)) ([f773e0f](https://github.com/fallenbagel/jellyseerr/commit/f773e0fb2a62f4f316ca7f8fe3d8dabdebae2ab7))
+* **job:** resolve edge case issue with season availability updates ([#1483](https://github.com/fallenbagel/jellyseerr/issues/1483)) ([77a36f9](https://github.com/fallenbagel/jellyseerr/commit/77a36f971444ee5dc0d15b2d34a8daaf4e1f28b5))
+* **mediarequest:** correct download sync for Radarr ([#1484](https://github.com/fallenbagel/jellyseerr/issues/1484)) ([c2d9d00](https://github.com/fallenbagel/jellyseerr/commit/c2d9d00b415fecbb5a8d7ca28a6ed76ea3ba3c19)), closes [#1376](https://github.com/fallenbagel/jellyseerr/issues/1376)
+* **proxy:** update http proxy to accept bypass list with undici v7 ([#1456](https://github.com/fallenbagel/jellyseerr/issues/1456)) ([9891a75](https://github.com/fallenbagel/jellyseerr/commit/9891a7577cc0874f41c38ff0e6e5a6b4d8315281)), closes [#1454](https://github.com/fallenbagel/jellyseerr/issues/1454)
+* **requestlist:** hide the remove from *arr button when no service exists ([#1457](https://github.com/fallenbagel/jellyseerr/issues/1457)) ([33e7a15](https://github.com/fallenbagel/jellyseerr/commit/33e7a153aa64461a715595d070fba53d52b34767)), closes [#1449](https://github.com/fallenbagel/jellyseerr/issues/1449)
+* **smtp-notification-test:** missing allowSelfSigned option in test function ([#1461](https://github.com/fallenbagel/jellyseerr/issues/1461)) ([b8425d6](https://github.com/fallenbagel/jellyseerr/commit/b8425d6388003322edd7b4b2473aeb24c06e4802))
+* **ui:** correct seasons badge order ([#1485](https://github.com/fallenbagel/jellyseerr/issues/1485)) ([f884ac9](https://github.com/fallenbagel/jellyseerr/commit/f884ac9c660d1931c8b3815dcaefd109da249f2a))
+* **ui:** move watch trailer button above the 4k request button ([#1465](https://github.com/fallenbagel/jellyseerr/issues/1465)) ([a6dd4a8](https://github.com/fallenbagel/jellyseerr/commit/a6dd4a8fedb9af9810581b1cc18cfea53b3cfd39)), closes [#1462](https://github.com/fallenbagel/jellyseerr/issues/1462)
+* **ui:** resolve streaming region dropdown overlap ([#1477](https://github.com/fallenbagel/jellyseerr/issues/1477)) ([767a241](https://github.com/fallenbagel/jellyseerr/commit/767a24164d6c9d101e613c53960985f4fbe2ce93)), closes [#1475](https://github.com/fallenbagel/jellyseerr/issues/1475)
+
+
+### Reverts
+
+* **airdate:** reverts airdate offset & changes relative time to only display date (not time) ([#1467](https://github.com/fallenbagel/jellyseerr/issues/1467)) ([8394eb5](https://github.com/fallenbagel/jellyseerr/commit/8394eb5ad405a90e840952d5977712e1ab890530)), closes [#1390](https://github.com/fallenbagel/jellyseerr/issues/1390)
+
+# [2.5.0](https://github.com/fallenbagel/jellyseerr/compare/v2.4.0...v2.5.0) (2025-03-11)
+
+
+### Bug Fixes
+
+* **ui:** correct media action icon size ([#1444](https://github.com/fallenbagel/jellyseerr/issues/1444)) ([771ecdf](https://github.com/fallenbagel/jellyseerr/commit/771ecdf7812004eec0f516cc424f9982936c8a2a)), closes [#1440](https://github.com/fallenbagel/jellyseerr/issues/1440)
+* **users:** correct user list for Postgres ([#1443](https://github.com/fallenbagel/jellyseerr/issues/1443)) ([5b998be](https://github.com/fallenbagel/jellyseerr/commit/5b998bef82388dccaaa462ff2ff3a526dd03338c)), closes [#1333](https://github.com/fallenbagel/jellyseerr/issues/1333)
+
+
+### Features
+
+* **helm:** upgrade jellyseerr to 2.4.0 ([#1438](https://github.com/fallenbagel/jellyseerr/issues/1438)) ([077e355](https://github.com/fallenbagel/jellyseerr/commit/077e355c775af92ff4dd2341543555d473c1abbb))
+
+
+### Reverts
+
+* reverts csrf-csrf back to csurf ([#1442](https://github.com/fallenbagel/jellyseerr/issues/1442)) ([21ab20b](https://github.com/fallenbagel/jellyseerr/commit/21ab20bba97102fe9eb9d4af4213a604c05e0acc)), closes [#1393](https://github.com/fallenbagel/jellyseerr/issues/1393)
+
+# [2.4.0](https://github.com/fallenbagel/jellyseerr/compare/v2.3.0...v2.4.0) (2025-03-10)
+
+
+### Bug Fixes
+
+* add email requirement for local users ([#1389](https://github.com/fallenbagel/jellyseerr/issues/1389)) ([f0a6055](https://github.com/fallenbagel/jellyseerr/commit/f0a605577469248a2a7c2170be8310e106131c59)), closes [#900](https://github.com/fallenbagel/jellyseerr/issues/900) [#1367](https://github.com/fallenbagel/jellyseerr/issues/1367)
+* **api:** make item endpoints user-independent ([#1413](https://github.com/fallenbagel/jellyseerr/issues/1413)) ([9cc6930](https://github.com/fallenbagel/jellyseerr/commit/9cc6930fed31c834201fe4e8a2a2f456b878dec6))
+* assign the keep-alive value explicitly ([#1368](https://github.com/fallenbagel/jellyseerr/issues/1368)) ([438ccfe](https://github.com/fallenbagel/jellyseerr/commit/438ccfe9c37f4848b84e60a2ce64687e0b4e4dc0)), closes [#1365](https://github.com/fallenbagel/jellyseerr/issues/1365)
+* corrected spelling errors in function names ([#1366](https://github.com/fallenbagel/jellyseerr/issues/1366)) ([e035cd8](https://github.com/fallenbagel/jellyseerr/commit/e035cd84ae24502f43cf842d6d10621f28719682))
+* disable first page revalidation in useSWRInfinite ([#1386](https://github.com/fallenbagel/jellyseerr/issues/1386)) ([d563b36](https://github.com/fallenbagel/jellyseerr/commit/d563b361869d8183041cb6aea91279e17a513070)), closes [#1380](https://github.com/fallenbagel/jellyseerr/issues/1380)
+* disallow admins to edit other admins in bulk edit ([#1340](https://github.com/fallenbagel/jellyseerr/issues/1340)) ([2dbd109](https://github.com/fallenbagel/jellyseerr/commit/2dbd1096d2756a7213209419d1d4da36e7267959)), closes [#1309](https://github.com/fallenbagel/jellyseerr/issues/1309)
+* **emby:** throw the right error message if no library exists ([#1415](https://github.com/fallenbagel/jellyseerr/issues/1415)) ([67bd639](https://github.com/fallenbagel/jellyseerr/commit/67bd639a432d724bb34b7d6fed76c0bb66d94147))
+* fix remove from *arr in item details ([#1387](https://github.com/fallenbagel/jellyseerr/issues/1387)) ([9712f56](https://github.com/fallenbagel/jellyseerr/commit/9712f5605471a673edb3d25048dc08d1addd58db))
+* **helm:** no change, fixing OCI manifest corruption ([#1310](https://github.com/fallenbagel/jellyseerr/issues/1310)) ([418f0c2](https://github.com/fallenbagel/jellyseerr/commit/418f0c2eb844e8814aca0d280292e9fb372cc118))
+* **jobs:** run plex/jellyfin jobs only for the relevant media server ([#1331](https://github.com/fallenbagel/jellyseerr/issues/1331)) ([2b7974f](https://github.com/fallenbagel/jellyseerr/commit/2b7974fa06f196b40de270ad24e54b227143b081)), closes [#1329](https://github.com/fallenbagel/jellyseerr/issues/1329)
+* make watchlist buttons consistent ([#1272](https://github.com/fallenbagel/jellyseerr/issues/1272)) ([f247642](https://github.com/fallenbagel/jellyseerr/commit/f247642b76ebefd9eeb8aed485573b5d6b133673)), closes [#1270](https://github.com/fallenbagel/jellyseerr/issues/1270)
+* **mediarequest:** optimise more typeorm lifecycle triggers ([#1376](https://github.com/fallenbagel/jellyseerr/issues/1376)) ([80927b9](https://github.com/fallenbagel/jellyseerr/commit/80927b97058a219fca9fa580243cb3f966fb0b37)), closes [#513](https://github.com/fallenbagel/jellyseerr/issues/513)
+* missing plex.tv url in images remotePatterns ([#1356](https://github.com/fallenbagel/jellyseerr/issues/1356)) ([b29959b](https://github.com/fallenbagel/jellyseerr/commit/b29959b0637fd8add9598d2a3d05f9a0972b65df))
+* **overriderules:** allows every user to be added to the override rules ([#1333](https://github.com/fallenbagel/jellyseerr/issues/1333)) ([af8d6b4](https://github.com/fallenbagel/jellyseerr/commit/af8d6b475c0040f7b96f04e3783ac8b4c702b3db))
+* **overriderules:** correct disabled condition for override rule creation ([#1419](https://github.com/fallenbagel/jellyseerr/issues/1419)) ([1de518d](https://github.com/fallenbagel/jellyseerr/commit/1de518d9154ea7809688c73ebefdcac66d27bdf8))
+* **overriderules:** enable override rules only when a service exists ([#1417](https://github.com/fallenbagel/jellyseerr/issues/1417)) ([4e44282](https://github.com/fallenbagel/jellyseerr/commit/4e44282387e7b511daecd961cdc9da98cb4b0139))
+* resolve a vulnerability with admin token ([#1345](https://github.com/fallenbagel/jellyseerr/issues/1345)) ([620135a](https://github.com/fallenbagel/jellyseerr/commit/620135aeac6d9fc284a3daddcafd1964474d2789))
+* **settings:** remove dns server option ([#1416](https://github.com/fallenbagel/jellyseerr/issues/1416)) ([ada467e](https://github.com/fallenbagel/jellyseerr/commit/ada467ecf40c7c27d57ae69ad515bd245d7bb639)), closes [#1266](https://github.com/fallenbagel/jellyseerr/issues/1266)
+* **setup:** resolve looping library validation error message ([#1316](https://github.com/fallenbagel/jellyseerr/issues/1316)) ([6ab4632](https://github.com/fallenbagel/jellyseerr/commit/6ab463285d566c18ef0b4034fbfd0b5863a4f7a5))
+* **watchlist:** disable Jellyseerr's watchlist for Plex users ([#1398](https://github.com/fallenbagel/jellyseerr/issues/1398)) ([4eddbaa](https://github.com/fallenbagel/jellyseerr/commit/4eddbaa71b7972b6db33976102501fb8b6333206)), closes [#1344](https://github.com/fallenbagel/jellyseerr/issues/1344)
+
+
+### Features
+
+* add a robots.txt file ([#1335](https://github.com/fallenbagel/jellyseerr/issues/1335)) ([24d3f52](https://github.com/fallenbagel/jellyseerr/commit/24d3f523fc07ff4b28d041b2a74cfb5ab0a788a7)), closes [#1323](https://github.com/fallenbagel/jellyseerr/issues/1323)
+* add linked accounts page ([#883](https://github.com/fallenbagel/jellyseerr/issues/883)) ([64f05bc](https://github.com/fallenbagel/jellyseerr/commit/64f05bcad6956f7e8cbe3fdf5f430af1f30ddd6d))
+* **airdatebadge:** convert airDate from UTC to local timezone ([#1390](https://github.com/fallenbagel/jellyseerr/issues/1390)) ([a790b1a](https://github.com/fallenbagel/jellyseerr/commit/a790b1abccfa9c3f8272ade8cd055017905dd87f)), closes [#1373](https://github.com/fallenbagel/jellyseerr/issues/1373)
+* **api:** make rottentomatoes matching more robust ([#1265](https://github.com/fallenbagel/jellyseerr/issues/1265)) ([907ba6f](https://github.com/fallenbagel/jellyseerr/commit/907ba6fdea0341e8d0f429eaf6aaa404dbc7daff))
+* **helm:** Add possibility to pass volumes and volume mounts ([#1291](https://github.com/fallenbagel/jellyseerr/issues/1291)) ([62c1a70](https://github.com/fallenbagel/jellyseerr/commit/62c1a70b373ee574ad9ff98d322085976dbc7868))
+* revamp login page and support disabling media server login ([#1286](https://github.com/fallenbagel/jellyseerr/issues/1286)) ([73d8efa](https://github.com/fallenbagel/jellyseerr/commit/73d8efaa54888b5282624e618c1461c23653f0b9))
+* **settings:** add a disclaimer for dns servers and ipv4 first settings ([#1375](https://github.com/fallenbagel/jellyseerr/issues/1375)) ([1176171](https://github.com/fallenbagel/jellyseerr/commit/117617188ed988bd8a90e9fbe8bada08d5b14513))
+* **ui:** prevent password manager interference & improve service links ([#1396](https://github.com/fallenbagel/jellyseerr/issues/1396)) ([e97a13e](https://github.com/fallenbagel/jellyseerr/commit/e97a13e1e46298be9f334c8e6c6028fb8a99c53d)), closes [#3989](https://github.com/fallenbagel/jellyseerr/issues/3989)
+* update Jellyfin logo ([#1359](https://github.com/fallenbagel/jellyseerr/issues/1359)) ([c181cee](https://github.com/fallenbagel/jellyseerr/commit/c181cee328eb867f90d906757b8bddaeb74ba9f2))
+* upgrade chart to 2.0.0 ([#1268](https://github.com/fallenbagel/jellyseerr/issues/1268)) ([0ee3e69](https://github.com/fallenbagel/jellyseerr/commit/0ee3e69a6101f5a8818b6d4c5654d84f6aac322b))
+
+# [2.3.0](https://github.com/fallenbagel/jellyseerr/compare/v2.2.3...v2.3.0) (2025-01-16)
+
+
+### Bug Fixes
+
+* correct typos for the special episodes setting ([#1209](https://github.com/fallenbagel/jellyseerr/issues/1209)) ([ebe7d11](https://github.com/fallenbagel/jellyseerr/commit/ebe7d11a5393f3d444dd9613854d6054af1ec58b)), closes [#1193](https://github.com/fallenbagel/jellyseerr/issues/1193) [#1208](https://github.com/fallenbagel/jellyseerr/issues/1208)
+* **externalapi:** clear cache after a request is made ([#1217](https://github.com/fallenbagel/jellyseerr/issues/1217)) ([f718cec](https://github.com/fallenbagel/jellyseerr/commit/f718cec23fccbfd16fdb792c2778cd543b751799)), closes [#1207](https://github.com/fallenbagel/jellyseerr/issues/1207)
+* **jellyfinlogin:** add proper error message when no admin user exists ([#1216](https://github.com/fallenbagel/jellyseerr/issues/1216)) ([ac90802](https://github.com/fallenbagel/jellyseerr/commit/ac908026dbb7ca06c0fb520bbb360120d6b87feb))
+* optimize media status update to avoid lifecycle hook triggers ([#1218](https://github.com/fallenbagel/jellyseerr/issues/1218)) ([656cd91](https://github.com/fallenbagel/jellyseerr/commit/656cd91c9c90e57914b7fedb097f29e21fb18090))
+* **overriderules:** allow override rules only when the service is created ([#1259](https://github.com/fallenbagel/jellyseerr/issues/1259)) ([ce1b39f](https://github.com/fallenbagel/jellyseerr/commit/ce1b39f73b953b6fa0a00948e72d24c43476bc5f))
+* prevent TypeORM subscribers from calling itself over and over ([#1215](https://github.com/fallenbagel/jellyseerr/issues/1215)) ([d67ec57](https://github.com/fallenbagel/jellyseerr/commit/d67ec571c5950f04b85f5a268b38eb026a156320))
+* resolve plex user mismatch due to caching issues ([#1242](https://github.com/fallenbagel/jellyseerr/issues/1242)) ([131a5a2](https://github.com/fallenbagel/jellyseerr/commit/131a5a2b0b1a235599940affc183b93c36f12ade)), closes [#1227](https://github.com/fallenbagel/jellyseerr/issues/1227)
+* **settingsmigrator:** prevent region migration from running multiple times ([#1255](https://github.com/fallenbagel/jellyseerr/issues/1255)) ([1c6f536](https://github.com/fallenbagel/jellyseerr/commit/1c6f5362d773c850a5e58b5013f0d65474467e9c)), closes [#1251](https://github.com/fallenbagel/jellyseerr/issues/1251)
+* **setup:** fix continue button disabled on refresh in setup 3 ([#1211](https://github.com/fallenbagel/jellyseerr/issues/1211)) ([0b331ca](https://github.com/fallenbagel/jellyseerr/commit/0b331ca579c75e546dcdbf0f1896e0f0ec3a89f1))
+* **setup:** plex library setting validation ([#1233](https://github.com/fallenbagel/jellyseerr/issues/1233)) ([b8dbfaa](https://github.com/fallenbagel/jellyseerr/commit/b8dbfaaed083734b05a28a05bf100941dc673ea7))
+* specify cached image type ([#1237](https://github.com/fallenbagel/jellyseerr/issues/1237)) ([d71ee58](https://github.com/fallenbagel/jellyseerr/commit/d71ee58302fe95c9c79e27b4edf317a98faf6f5c))
+* **ui:** resolve streaming region dropdown overlap ([#1210](https://github.com/fallenbagel/jellyseerr/issues/1210)) ([2f0e493](https://github.com/fallenbagel/jellyseerr/commit/2f0e4932572497322df0d7d7f4377aeb9cc35d5b)), closes [#1206](https://github.com/fallenbagel/jellyseerr/issues/1206)
+* **users:** correct request count query for PostgreSQL compatibility ([#1213](https://github.com/fallenbagel/jellyseerr/issues/1213)) ([f3ebf60](https://github.com/fallenbagel/jellyseerr/commit/f3ebf6028b23f803a1c8801b1541a444e8856421))
+
+
+### Features
+
+* Add latest tag to ghcr container image ([#1224](https://github.com/fallenbagel/jellyseerr/issues/1224)) ([b9dc9bc](https://github.com/fallenbagel/jellyseerr/commit/b9dc9bceb5805889c1ea3157c3ace880865eaf9c))
+* Add release charts workflow ([#1140](https://github.com/fallenbagel/jellyseerr/issues/1140)) ([3cc34b0](https://github.com/fallenbagel/jellyseerr/commit/3cc34b0db6b868a6133408a69a60b7eab69d9ea3))
+* **settings:** add settings for custom DNS servers and IPv4 resolution first ([#1266](https://github.com/fallenbagel/jellyseerr/issues/1266)) ([7fcc0eb](https://github.com/fallenbagel/jellyseerr/commit/7fcc0eb66d907e74b72197d6abee511150ab5e1e))
+
+# [2.3.0](https://github.com/fallenbagel/jellyseerr/compare/v2.2.3...v2.3.0) (2025-01-16)
+
+
+### Bug Fixes
+
+* correct typos for the special episodes setting ([#1209](https://github.com/fallenbagel/jellyseerr/issues/1209)) ([ebe7d11](https://github.com/fallenbagel/jellyseerr/commit/ebe7d11a5393f3d444dd9613854d6054af1ec58b)), closes [#1193](https://github.com/fallenbagel/jellyseerr/issues/1193) [#1208](https://github.com/fallenbagel/jellyseerr/issues/1208)
+* **externalapi:** clear cache after a request is made ([#1217](https://github.com/fallenbagel/jellyseerr/issues/1217)) ([f718cec](https://github.com/fallenbagel/jellyseerr/commit/f718cec23fccbfd16fdb792c2778cd543b751799)), closes [#1207](https://github.com/fallenbagel/jellyseerr/issues/1207)
+* **jellyfinlogin:** add proper error message when no admin user exists ([#1216](https://github.com/fallenbagel/jellyseerr/issues/1216)) ([ac90802](https://github.com/fallenbagel/jellyseerr/commit/ac908026dbb7ca06c0fb520bbb360120d6b87feb))
+* optimize media status update to avoid lifecycle hook triggers ([#1218](https://github.com/fallenbagel/jellyseerr/issues/1218)) ([656cd91](https://github.com/fallenbagel/jellyseerr/commit/656cd91c9c90e57914b7fedb097f29e21fb18090))
+* **overriderules:** allow override rules only when the service is created ([#1259](https://github.com/fallenbagel/jellyseerr/issues/1259)) ([ce1b39f](https://github.com/fallenbagel/jellyseerr/commit/ce1b39f73b953b6fa0a00948e72d24c43476bc5f))
+* prevent TypeORM subscribers from calling itself over and over ([#1215](https://github.com/fallenbagel/jellyseerr/issues/1215)) ([d67ec57](https://github.com/fallenbagel/jellyseerr/commit/d67ec571c5950f04b85f5a268b38eb026a156320))
+* resolve plex user mismatch due to caching issues ([#1242](https://github.com/fallenbagel/jellyseerr/issues/1242)) ([131a5a2](https://github.com/fallenbagel/jellyseerr/commit/131a5a2b0b1a235599940affc183b93c36f12ade)), closes [#1227](https://github.com/fallenbagel/jellyseerr/issues/1227)
+* **settingsmigrator:** prevent region migration from running multiple times ([#1255](https://github.com/fallenbagel/jellyseerr/issues/1255)) ([1c6f536](https://github.com/fallenbagel/jellyseerr/commit/1c6f5362d773c850a5e58b5013f0d65474467e9c)), closes [#1251](https://github.com/fallenbagel/jellyseerr/issues/1251)
+* **setup:** fix continue button disabled on refresh in setup 3 ([#1211](https://github.com/fallenbagel/jellyseerr/issues/1211)) ([0b331ca](https://github.com/fallenbagel/jellyseerr/commit/0b331ca579c75e546dcdbf0f1896e0f0ec3a89f1))
+* **setup:** plex library setting validation ([#1233](https://github.com/fallenbagel/jellyseerr/issues/1233)) ([b8dbfaa](https://github.com/fallenbagel/jellyseerr/commit/b8dbfaaed083734b05a28a05bf100941dc673ea7))
+* specify cached image type ([#1237](https://github.com/fallenbagel/jellyseerr/issues/1237)) ([d71ee58](https://github.com/fallenbagel/jellyseerr/commit/d71ee58302fe95c9c79e27b4edf317a98faf6f5c))
+* **ui:** resolve streaming region dropdown overlap ([#1210](https://github.com/fallenbagel/jellyseerr/issues/1210)) ([2f0e493](https://github.com/fallenbagel/jellyseerr/commit/2f0e4932572497322df0d7d7f4377aeb9cc35d5b)), closes [#1206](https://github.com/fallenbagel/jellyseerr/issues/1206)
+* **users:** correct request count query for PostgreSQL compatibility ([#1213](https://github.com/fallenbagel/jellyseerr/issues/1213)) ([f3ebf60](https://github.com/fallenbagel/jellyseerr/commit/f3ebf6028b23f803a1c8801b1541a444e8856421))
+
+
+### Features
+
+* Add latest tag to ghcr container image ([#1224](https://github.com/fallenbagel/jellyseerr/issues/1224)) ([b9dc9bc](https://github.com/fallenbagel/jellyseerr/commit/b9dc9bceb5805889c1ea3157c3ace880865eaf9c))
+* Add release charts workflow ([#1140](https://github.com/fallenbagel/jellyseerr/issues/1140)) ([3cc34b0](https://github.com/fallenbagel/jellyseerr/commit/3cc34b0db6b868a6133408a69a60b7eab69d9ea3))
+* **settings:** add settings for custom DNS servers and IPv4 resolution first ([#1266](https://github.com/fallenbagel/jellyseerr/issues/1266)) ([7fcc0eb](https://github.com/fallenbagel/jellyseerr/commit/7fcc0eb66d907e74b72197d6abee511150ab5e1e))
+
+## [2.2.3](https://github.com/fallenbagel/jellyseerr/compare/v2.2.2...v2.2.3) (2024-12-30)
+
+
+### Bug Fixes
+
+* properly fetch sonarr/radarr specific override rules ([#1199](https://github.com/fallenbagel/jellyseerr/issues/1199)) ([814a735](https://github.com/fallenbagel/jellyseerr/commit/814a7357c0c7418091e8d3e911adc403811c9dfe))
+* **usersettings:** fix the streaming region setting toggling itself ([#1203](https://github.com/fallenbagel/jellyseerr/issues/1203)) ([7e94ad7](https://github.com/fallenbagel/jellyseerr/commit/7e94ad721026a03d3ae640ee2deb60e321cabf10)), closes [#1200](https://github.com/fallenbagel/jellyseerr/issues/1200)
+
+## [2.2.2](https://github.com/fallenbagel/jellyseerr/compare/v2.2.1...v2.2.2) (2024-12-30)
+
+
+### Bug Fixes
+
+* **overriderules:** apply override rules to tv shows during request ([#1198](https://github.com/fallenbagel/jellyseerr/issues/1198)) ([f8a8ebd](https://github.com/fallenbagel/jellyseerr/commit/f8a8ebdf76f939ccc28ce7b39343e3a606c90b33)), closes [#1197](https://github.com/fallenbagel/jellyseerr/issues/1197) [#1195](https://github.com/fallenbagel/jellyseerr/issues/1195)
+
+## [2.2.1](https://github.com/fallenbagel/jellyseerr/compare/v2.2.0...v2.2.1) (2024-12-30)
+
+
+### Bug Fixes
+
+* **overriderules:** apply override rules during request only for non-admin/non-auto-approve users ([#1197](https://github.com/fallenbagel/jellyseerr/issues/1197)) ([8da4870](https://github.com/fallenbagel/jellyseerr/commit/8da48709977fa0111225c3519f9128bea41867fc)), closes [#1195](https://github.com/fallenbagel/jellyseerr/issues/1195)
+
+# [2.2.0](https://github.com/fallenbagel/jellyseerr/compare/v2.1.0...v2.2.0) (2024-12-29)
+
+
+### Bug Fixes
+
+* **avatarproxy:** add support for Emby avatars ([#1128](https://github.com/fallenbagel/jellyseerr/issues/1128)) ([17418f8](https://github.com/fallenbagel/jellyseerr/commit/17418f82af53362338aebe9602373a3c8fa027f7)), closes [#1101](https://github.com/fallenbagel/jellyseerr/issues/1101)
+* **blacklist:** remove a "undefined" appearing when the blacklist modal closes ([#1142](https://github.com/fallenbagel/jellyseerr/issues/1142)) ([b01f98f](https://github.com/fallenbagel/jellyseerr/commit/b01f98f7e280a037eba303eeaa836f6623daa440))
+* **discover:** display recent requests even if there is an error with *arr ([#1141](https://github.com/fallenbagel/jellyseerr/issues/1141)) ([fa443c0](https://github.com/fallenbagel/jellyseerr/commit/fa443c05bedfca8208bfb05ab02c3b0e678e4ca0))
+* **discover:** resolve a typing issue with the WatchlistItem interface ([#1156](https://github.com/fallenbagel/jellyseerr/issues/1156)) ([de6e591](https://github.com/fallenbagel/jellyseerr/commit/de6e591baedacb33704216842dddaa2b96bfae19))
+* **emby:** change default value of Accept-Encoding header ([#1157](https://github.com/fallenbagel/jellyseerr/issues/1157)) ([7c734bc](https://github.com/fallenbagel/jellyseerr/commit/7c734bc8732a511e62edfcc371028ead6b6f1b12))
+* fix PostgreSQL migrations and TelegramMessageThreadId migration ([#1171](https://github.com/fallenbagel/jellyseerr/issues/1171)) ([0491a04](https://github.com/fallenbagel/jellyseerr/commit/0491a04ef1816e81bb495746cc529fc621e4e147))
+* handle non-existent rottentomatoes rating for movies ([#1169](https://github.com/fallenbagel/jellyseerr/issues/1169)) ([347a24a](https://github.com/fallenbagel/jellyseerr/commit/347a24a97b354725c4ccb3b5a07793b96ff60b80))
+* remove non-null requirement for some fields ([#1175](https://github.com/fallenbagel/jellyseerr/issues/1175)) ([13d15d1](https://github.com/fallenbagel/jellyseerr/commit/13d15d1dcf4a80bc0b544fecbeced706f2dbd816)), closes [#628](https://github.com/fallenbagel/jellyseerr/issues/628)
+* **requestlist:** use default value of sort direction only if valid ([#1174](https://github.com/fallenbagel/jellyseerr/issues/1174)) ([59c22cc](https://github.com/fallenbagel/jellyseerr/commit/59c22ccc089c960b523ccfb69efc680b2687c353)), closes [#1147](https://github.com/fallenbagel/jellyseerr/issues/1147)
+* **server/settings:** write settings to a temp file then move to avoid corruption ([#1067](https://github.com/fallenbagel/jellyseerr/issues/1067)) ([01bbece](https://github.com/fallenbagel/jellyseerr/commit/01bbeced65b82f5041462cd7a6c9016274acade4))
+* **ui:** allow thetvdb images for unmatched series ([#1105](https://github.com/fallenbagel/jellyseerr/issues/1105)) ([9b151fe](https://github.com/fallenbagel/jellyseerr/commit/9b151feb4f44d631b44c88c089f184c4c93161c5)), closes [#1075](https://github.com/fallenbagel/jellyseerr/issues/1075)
+* **ui:** display Rotten Tomatoes for 0% ratings ([#1178](https://github.com/fallenbagel/jellyseerr/issues/1178)) ([5345207](https://github.com/fallenbagel/jellyseerr/commit/534520794071d8530d6325460e61dabfcb46fbf0)), closes [#1166](https://github.com/fallenbagel/jellyseerr/issues/1166)
+* **ui:** resize streaming service logos ([#1106](https://github.com/fallenbagel/jellyseerr/issues/1106)) ([fe5d016](https://github.com/fallenbagel/jellyseerr/commit/fe5d016929d18c38aef7a3d48e4828188131e025)), closes [#1103](https://github.com/fallenbagel/jellyseerr/issues/1103)
+* use less strict validation for external URLs ([#1104](https://github.com/fallenbagel/jellyseerr/issues/1104)) ([14f316a](https://github.com/fallenbagel/jellyseerr/commit/14f316a9a6d91c25c43e07ae66923785f90b1fdf)), closes [#1068](https://github.com/fallenbagel/jellyseerr/issues/1068)
+* use links instead of buttons for external links in movie/tv details page ([#923](https://github.com/fallenbagel/jellyseerr/issues/923)) ([5776715](https://github.com/fallenbagel/jellyseerr/commit/57767156f79cb0bcb761f6fc0907d747f126e146))
+* use tmdb first as metadata provider and fallback to tvdb ([#1138](https://github.com/fallenbagel/jellyseerr/issues/1138)) ([84fd884](https://github.com/fallenbagel/jellyseerr/commit/84fd884052ea2177c92d144367c4b4ed1dde3b73)), closes [#1137](https://github.com/fallenbagel/jellyseerr/issues/1137)
+* **usediscover hook:** fixing duplicate movies ([#708](https://github.com/fallenbagel/jellyseerr/issues/708)) ([39dbb7f](https://github.com/fallenbagel/jellyseerr/commit/39dbb7f7e59cf4b1b5f029089c6b1ea6a0d7e5f5))
+* **usersettings:** allow unset email and add more explicit email error message ([#1096](https://github.com/fallenbagel/jellyseerr/issues/1096)) ([39a5ccb](https://github.com/fallenbagel/jellyseerr/commit/39a5ccb7f3a6ed4e93b12e11021bb30515936ce7))
+
+
+### Features
+
+* add a setting for special episodes ([#1193](https://github.com/fallenbagel/jellyseerr/issues/1193)) ([b6e2e6c](https://github.com/fallenbagel/jellyseerr/commit/b6e2e6ce615cb94cea8d2335140fe245a0ca2d8a))
+* add postgres support + migrations ([#628](https://github.com/fallenbagel/jellyseerr/issues/628)) ([44a9221](https://github.com/fallenbagel/jellyseerr/commit/44a9221a9dca501fa57c0bcbd743aed9889059ff)), closes [#186](https://github.com/fallenbagel/jellyseerr/issues/186)
+* **helm:** add base helm chart ([#1116](https://github.com/fallenbagel/jellyseerr/issues/1116)) ([27e3d46](https://github.com/fallenbagel/jellyseerr/commit/27e3d465bd7eaa3f382c961220f8af1860a15c7f))
+* **notifications:** added telegram thread id's ([#1145](https://github.com/fallenbagel/jellyseerr/issues/1145)) ([d76d794](https://github.com/fallenbagel/jellyseerr/commit/d76d79441142ccc6fe2357549f39a1fba3546ff9))
+* **notifications:** improve discord notifications ([#1102](https://github.com/fallenbagel/jellyseerr/issues/1102)) ([5c24e79](https://github.com/fallenbagel/jellyseerr/commit/5c24e79b1dddc3c8421e57e67302fa3dc064f87f))
+* override rules ([#945](https://github.com/fallenbagel/jellyseerr/issues/945)) ([9a59529](https://github.com/fallenbagel/jellyseerr/commit/9a595296dbdd00bb3477052b53412e6019667740))
+* **requestlist:** sort direction ([#1147](https://github.com/fallenbagel/jellyseerr/issues/1147)) ([66a5ab4](https://github.com/fallenbagel/jellyseerr/commit/66a5ab41ab646501f72a658782e8a89f9faf939f))
+* **usersettings:** add separate setting for streaming region ([#993](https://github.com/fallenbagel/jellyseerr/issues/993)) ([89831f7](https://github.com/fallenbagel/jellyseerr/commit/89831f70909df0a76dfa8a027702e4e5f9b57be8)), closes [#890](https://github.com/fallenbagel/jellyseerr/issues/890)
+
+# [2.1.0](https://github.com/fallenbagel/jellyseerr/compare/v2.0.1...v2.1.0) (2024-11-12)
+
+
+### Bug Fixes
+
+* **blacklist:** request data only when modal is shown, remove useless ratelimit and lazy load blacklist ([#1084](https://github.com/fallenbagel/jellyseerr/issues/1084)) ([694913c](https://github.com/fallenbagel/jellyseerr/commit/694913c767c558147f413e2375b2512567541127))
+* cache Jellyfin/Emby avatars from API ([#1045](https://github.com/fallenbagel/jellyseerr/issues/1045)) ([0bbcfcb](https://github.com/fallenbagel/jellyseerr/commit/0bbcfcbd5e03137aba35ceb07e42f623aefa41d7))
+* **externalapi:** extract basic auth and pass it through header ([#1062](https://github.com/fallenbagel/jellyseerr/issues/1062)) ([cf59102](https://github.com/fallenbagel/jellyseerr/commit/cf59102ef91fa0e907cc6369b0fe60b503c823ca)), closes [#1027](https://github.com/fallenbagel/jellyseerr/issues/1027)
+* fixes wrong avatar rendered for the modifiedBy user in request list ([#1028](https://github.com/fallenbagel/jellyseerr/issues/1028)) ([cbb1a74](https://github.com/fallenbagel/jellyseerr/commit/cbb1a74526ef5c003b7081c31146c52e7e551d60)), closes [#1017](https://github.com/fallenbagel/jellyseerr/issues/1017)
+* **i18n:** update extractMessages function for better escaping of characters ([#1079](https://github.com/fallenbagel/jellyseerr/issues/1079)) ([a2d2fd3](https://github.com/fallenbagel/jellyseerr/commit/a2d2fd3c2a53fc98d6288bd049fd8e37a1914280))
+* remove language profiles dropdown for Sonarr v4 ([#1000](https://github.com/fallenbagel/jellyseerr/issues/1000)) ([d331798](https://github.com/fallenbagel/jellyseerr/commit/d331798b28a7bd32a27fc0ccbad2354be2e15b02)), closes [#207](https://github.com/fallenbagel/jellyseerr/issues/207)
+* resolve error when setup on second attempt ([#1061](https://github.com/fallenbagel/jellyseerr/issues/1061)) ([64f4610](https://github.com/fallenbagel/jellyseerr/commit/64f4610b9ffcad01c24ecdd81b8b3a2f3db4c98d))
+* **setup:** add leading slash validation for baseUrl ([#1083](https://github.com/fallenbagel/jellyseerr/issues/1083)) ([2829c25](https://github.com/fallenbagel/jellyseerr/commit/2829c2548aa0cd03f92433d3bc3b9b2739e98486))
+* update i18n translations ([#1090](https://github.com/fallenbagel/jellyseerr/issues/1090)) ([f25b32a](https://github.com/fallenbagel/jellyseerr/commit/f25b32aec8ec3c2fd40ccfc6a83f18ddc99c1a15))
+* use fs/promises for settings ([#1057](https://github.com/fallenbagel/jellyseerr/issues/1057)) ([f2ed101](https://github.com/fallenbagel/jellyseerr/commit/f2ed101e522561dab8563b744d908ff036c957c5))
+
+
+### Features
+
+* add a warning if permissions are missing from config folder ([#1030](https://github.com/fallenbagel/jellyseerr/issues/1030)) ([f2b6315](https://github.com/fallenbagel/jellyseerr/commit/f2b63156d1d4aa903eb261d2c80c059c39d9091b))
+* add bypass list, bypass local addresses and username/password to proxy setting ([#1059](https://github.com/fallenbagel/jellyseerr/issues/1059)) ([ca838a0](https://github.com/fallenbagel/jellyseerr/commit/ca838a00fa4acb0ccdfbac8be4cf7fde493346f7))
+* add more logs to migrations and create a settings backup ([#1036](https://github.com/fallenbagel/jellyseerr/issues/1036)) ([326001c](https://github.com/fallenbagel/jellyseerr/commit/326001c3ecc92dc730f327130a71e797882a62b9))
+* exit Jellyseerr when migration fails ([#1026](https://github.com/fallenbagel/jellyseerr/issues/1026)) ([a2b3408](https://github.com/fallenbagel/jellyseerr/commit/a2b3408c9aa5e22e1193f535c969325254f08193))
+* proxy setting ([#1031](https://github.com/fallenbagel/jellyseerr/issues/1031)) ([4b4eeb6](https://github.com/fallenbagel/jellyseerr/commit/4b4eeb6ec707e0971fe8745910edbfb546bf25fe))
+
+## [2.0.1](https://github.com/fallenbagel/jellyseerr/compare/v2.0.0...v2.0.1) (2024-10-17)
+
+
+### Bug Fixes
+
+* fetch override to attach XSRF token to fix csrfProtection issue ([#1014](https://github.com/fallenbagel/jellyseerr/issues/1014)) ([4945b54](https://github.com/fallenbagel/jellyseerr/commit/4945b5429848b36fc0ee41cf0277ed79f53d8286)), closes [#1011](https://github.com/fallenbagel/jellyseerr/issues/1011)
+* handle non-existent rottentomatoes rating ([#1018](https://github.com/fallenbagel/jellyseerr/issues/1018)) ([a351264](https://github.com/fallenbagel/jellyseerr/commit/a351264b878b2660ae7a6415f26d38b52015c591))
+* rewrite avatarproxy and CachedImage ([#1016](https://github.com/fallenbagel/jellyseerr/issues/1016)) ([4e48fdf](https://github.com/fallenbagel/jellyseerr/commit/4e48fdf2cb9f76ae5c25073b585718650abd3288)), closes [#1012](https://github.com/fallenbagel/jellyseerr/issues/1012) [#1013](https://github.com/fallenbagel/jellyseerr/issues/1013)
+* use jellyfinMediaId4k for mediaUrl4k ([#1006](https://github.com/fallenbagel/jellyseerr/issues/1006)) ([a0f80fe](https://github.com/fallenbagel/jellyseerr/commit/a0f80fe7647ef4a9025ca93407cd21ddc640fed1)), closes [#520](https://github.com/fallenbagel/jellyseerr/issues/520)
+
+# [2.0.0](https://github.com/fallenbagel/jellyseerr/compare/v1.9.2...v2.0.0) (2024-10-15)
+
+
+### Bug Fixes
+
+* abort availability sync job if auth token invalid/connection lost ([#845](https://github.com/fallenbagel/jellyseerr/issues/845)) ([bdee340](https://github.com/fallenbagel/jellyseerr/commit/bdee34053080c8975a88ba16a9e8f402e10fe7e1))
+* add an error message to say when an email is already taken ([#947](https://github.com/fallenbagel/jellyseerr/issues/947)) ([89e0a83](https://github.com/fallenbagel/jellyseerr/commit/89e0a831ec85a6905f539f59b7523bb1feb90bcf))
+* add missing brackets ([#888](https://github.com/fallenbagel/jellyseerr/issues/888)) ([6cea8bb](https://github.com/fallenbagel/jellyseerr/commit/6cea8bba592b8db566b4d8147630385f5c377f1b))
+* add missing content-type header ([#887](https://github.com/fallenbagel/jellyseerr/issues/887)) ([2be9c7d](https://github.com/fallenbagel/jellyseerr/commit/2be9c7dcc1f418726a19e99cfdb3933257a03c6f))
+* add missing header when creating an issue ([#879](https://github.com/fallenbagel/jellyseerr/issues/879)) ([084e1b2](https://github.com/fallenbagel/jellyseerr/commit/084e1b224e109f0f8279741b9a5ead138396d7f8))
+* add missing parameter to delete requests from ExternalAPI ([#904](https://github.com/fallenbagel/jellyseerr/issues/904)) ([36d98a2](https://github.com/fallenbagel/jellyseerr/commit/36d98a2681921a8770027b78878688f2782e8b77)), closes [#903](https://github.com/fallenbagel/jellyseerr/issues/903)
+* **api:** fix nextjs error handler ([#882](https://github.com/fallenbagel/jellyseerr/issues/882)) ([0116c13](https://github.com/fallenbagel/jellyseerr/commit/0116c13e0632d1ccec43299fbb10cd71db45bc29))
+* **api:** handle non-existent ratings on IMDb ([#822](https://github.com/fallenbagel/jellyseerr/issues/822)) ([74a2d25](https://github.com/fallenbagel/jellyseerr/commit/74a2d25f153b07a0cae5b44adca5fa1fed5a3b9e))
+* **api:** save new password when reset password of local account ([#886](https://github.com/fallenbagel/jellyseerr/issues/886)) ([5cc4389](https://github.com/fallenbagel/jellyseerr/commit/5cc43898256b130c2576f34a3d4e7ce6a3940d3e))
+* **blacklist:** add blacklist to mobile menu ([#980](https://github.com/fallenbagel/jellyseerr/issues/980)) ([f390da4](https://github.com/fallenbagel/jellyseerr/commit/f390da486625a22951956ba96867de63f73bfc2b)), closes [#979](https://github.com/fallenbagel/jellyseerr/issues/979)
+* change SeriesSearch to MissingEpisodeSearch for season requests ([#711](https://github.com/fallenbagel/jellyseerr/issues/711)) ([ee7e91c](https://github.com/fallenbagel/jellyseerr/commit/ee7e91c7c948b17b556a625919eb1252a721bb6e))
+* **docker:** add postinstall script ([#839](https://github.com/fallenbagel/jellyseerr/issues/839)) ([f714132](https://github.com/fallenbagel/jellyseerr/commit/f7141329094d88eb0940b1db1f21376142cb8893))
+* enhance error messages when Fetch API fails ([#893](https://github.com/fallenbagel/jellyseerr/issues/893)) ([fccfca6](https://github.com/fallenbagel/jellyseerr/commit/fccfca6ed06c8dc599e1ea4b1b3dbac48eb3a7f6))
+* handle status badge for season packs ([#927](https://github.com/fallenbagel/jellyseerr/issues/927)) ([80f6301](https://github.com/fallenbagel/jellyseerr/commit/80f63017ac5e9b1720a19c761dbef4dd517f1c2c))
+* length of undefined on users warnings ([#875](https://github.com/fallenbagel/jellyseerr/issues/875)) ([c600566](https://github.com/fallenbagel/jellyseerr/commit/c600566ac0045c2314f9013b063007b087ee4327))
+* remove DNS caching ([#837](https://github.com/fallenbagel/jellyseerr/issues/837)) ([268c7df](https://github.com/fallenbagel/jellyseerr/commit/268c7df28eea8b911d6a53297f5ce296983067ce))
+* remove email requirement for the user, and use the username if no email provided ([#900](https://github.com/fallenbagel/jellyseerr/issues/900)) ([d5f817e](https://github.com/fallenbagel/jellyseerr/commit/d5f817e734131cdacc229361d9498a095af57950))
+* remove protocol-relative URLs from next/image ([#889](https://github.com/fallenbagel/jellyseerr/issues/889)) ([c80d9a8](https://github.com/fallenbagel/jellyseerr/commit/c80d9a853a2a3451293a5382ef183c18add0c040))
+* resize episode preview image ([#842](https://github.com/fallenbagel/jellyseerr/issues/842)) ([96ba53f](https://github.com/fallenbagel/jellyseerr/commit/96ba53fecc7b9d269f0d974051ab62836b0102bc))
+* resize header image in network and studio pages ([#902](https://github.com/fallenbagel/jellyseerr/issues/902)) ([4220855](https://github.com/fallenbagel/jellyseerr/commit/422085523e5dfc132f3c3ca19eaa87117828b7be))
+* rewrite request from axios to Fetch ([#920](https://github.com/fallenbagel/jellyseerr/issues/920)) ([9aee888](https://github.com/fallenbagel/jellyseerr/commit/9aee8887d3cca6e018f4be1c8400c22e86bf8dab))
+* rewrite the rate limit utility ([#896](https://github.com/fallenbagel/jellyseerr/issues/896)) ([3fc14c9](https://github.com/fallenbagel/jellyseerr/commit/3fc14c9e2262463afec666e7f54e38d0d36cff68))
+* **session:** set the correct TTL for the cookie store ([#992](https://github.com/fallenbagel/jellyseerr/issues/992)) ([96e1d40](https://github.com/fallenbagel/jellyseerr/commit/96e1d40304749ce00d2ff7359efc39a1d9724358)), closes [#991](https://github.com/fallenbagel/jellyseerr/issues/991)
+* set correct user type when importing from emby ([#949](https://github.com/fallenbagel/jellyseerr/issues/949)) ([e57d265](https://github.com/fallenbagel/jellyseerr/commit/e57d2654d1c634a91649722d3a2bf4d73c4a02ca)), closes [#948](https://github.com/fallenbagel/jellyseerr/issues/948)
+* **setup:** page display when homepage is loading ([#940](https://github.com/fallenbagel/jellyseerr/issues/940)) ([7423bbb](https://github.com/fallenbagel/jellyseerr/commit/7423bbbffc5bee2e52e3348254f035dc8527d973))
+* **tmdb:** fallback movie/show overview to English when none is available in requested locale ([#928](https://github.com/fallenbagel/jellyseerr/issues/928)) ([12f908d](https://github.com/fallenbagel/jellyseerr/commit/12f908de7f5fbd717a5f151858b6edee3be13ed9)), closes [#925](https://github.com/fallenbagel/jellyseerr/issues/925)
+* update the filter removing existing users from Jellyfin import modal ([#924](https://github.com/fallenbagel/jellyseerr/issues/924)) ([61dcd8e](https://github.com/fallenbagel/jellyseerr/commit/61dcd8e487d7886773ccb12501623c17838476e5))
+
+
+### Code Refactoring
+
+* **jellyfin:** abstract jellyfin hostname, updated ui to reflect it, better validation ([#773](https://github.com/fallenbagel/jellyseerr/issues/773)) ([38ad875](https://github.com/fallenbagel/jellyseerr/commit/38ad875dd7848b4e92ac3ccdd16dbf785f6a5c4d))
+
+
+### Features
+
+* add environment variable for API key ([#831](https://github.com/fallenbagel/jellyseerr/issues/831)) ([45ef150](https://github.com/fallenbagel/jellyseerr/commit/45ef150e36944d456cc9440574b5ac75f2e4bbc1))
+* adds status filter for tv shows ([#796](https://github.com/fallenbagel/jellyseerr/issues/796)) ([cfd1bc2](https://github.com/fallenbagel/jellyseerr/commit/cfd1bc253557d6e19725743b8aa9a2fa33bbe760)), closes [#605](https://github.com/fallenbagel/jellyseerr/issues/605)
+* allow request managers to delete data from sonarr/radarr ([#644](https://github.com/fallenbagel/jellyseerr/issues/644)) ([a5d22ba](https://github.com/fallenbagel/jellyseerr/commit/a5d22ba5b83dd0e812b16f06476d993b5d59cb2a))
+* blacklist items from Discover page ([#632](https://github.com/fallenbagel/jellyseerr/issues/632)) ([818aa60](https://github.com/fallenbagel/jellyseerr/commit/818aa60aac185da07bfb71b08e0448939b63a736)), closes [#490](https://github.com/fallenbagel/jellyseerr/issues/490)
+* Jellyfin/Emby server type setup ([#685](https://github.com/fallenbagel/jellyseerr/issues/685)) ([15cb949](https://github.com/fallenbagel/jellyseerr/commit/15cb949f1f2e617853f90ae7bb8ae5d6622f610e))
+* **jellyfinapi:** switch to API tokens instead of auth tokens ([#868](https://github.com/fallenbagel/jellyseerr/issues/868)) ([bd4da6d](https://github.com/fallenbagel/jellyseerr/commit/bd4da6d5fc8cb55c2bc3d9a8336787cbd30814d0))
+* Option on item's page to add/remove from watchlist ([#781](https://github.com/fallenbagel/jellyseerr/issues/781)) ([2348f23](https://github.com/fallenbagel/jellyseerr/commit/2348f23f433195d64dee3e6eeede296fca5fdbc9)), closes [#730](https://github.com/fallenbagel/jellyseerr/issues/730)
+* refresh monitored downloads before getting queue items ([#994](https://github.com/fallenbagel/jellyseerr/issues/994)) ([92ba262](https://github.com/fallenbagel/jellyseerr/commit/92ba26207dcb1ddd696e0f01931d2609c521ae45)), closes [#866](https://github.com/fallenbagel/jellyseerr/issues/866)
+* show quality profile on request ([#847](https://github.com/fallenbagel/jellyseerr/issues/847)) ([6445332](https://github.com/fallenbagel/jellyseerr/commit/64453320d36595e75dcb710dfd43997bf2d2acd5))
+* **translation:** added full Hebrew translation ([#871](https://github.com/fallenbagel/jellyseerr/issues/871)) ([c96ca67](https://github.com/fallenbagel/jellyseerr/commit/c96ca6742e0a6d5685319c52f995fe06e439a450))
+* update Plex logo ([#884](https://github.com/fallenbagel/jellyseerr/issues/884)) ([3a363ae](https://github.com/fallenbagel/jellyseerr/commit/3a363ae1ffa7f384be6f7d25f8558b1e55a73fb3))
+
+
+### Reverts
+
+* fix(api): fix nextjs error handler ([#882](https://github.com/fallenbagel/jellyseerr/issues/882)) ([#892](https://github.com/fallenbagel/jellyseerr/issues/892)) ([62dbde4](https://github.com/fallenbagel/jellyseerr/commit/62dbde448c7f7d530de8534bb8538452d0f91276))
+
+
+### BREAKING CHANGES
+
+* This commit deprecates the JELLYFIN_TYPE variable to identify Emby media server and
+instead rely on the mediaServerType that is set in the `settings.json`. Existing environment
+variable users can log out and log back in to set the mediaServerType to `3` (Emby).
+
+* feat(api): add severType to the api
+* This adds a serverType to the `/auth/jellyfin` which requires a serverType to be
+set (`jellyfin`/`emby`)
+
+* refactor: use enums for serverType and rename selectedservice to serverType
+
+* refactor(auth): jellyfin/emby authentication to set MediaServerType
+
+* fix: issue page formatMessage for 4k media
+
+* refactor: cleaner way of handling serverType change using MediaServerType instead of strings
+
+instead of using strings now it will use MediaServerType enums for serverType
+
+* revert: removed conditional render of the auto-request permission
+
+reverts the conditional render toshow the auto-request permission if the mediaServerType was set to
+Plex as this should be handled in a different PR and Cypress tests should be modified
+accordingly(currently cypress test would fail if this conditional check is there)
+
+* feat: add server type step to setup
+
+* feat: migrate existing emby setups to use emby mediaServerType
+
+* fix: scan jobs not running when media server type is emby
+
+* fix: emby media server type migration
+
+* refactor: change emby logo to full logo
+
+* style: decrease emby logo size in setup screen
+
+* refactor: use title case for servertype i18n message
+
+* refactor(i18n): fix a typo
+
+* refactor: use enums instead of numbers
+
+* fix: remove old references to JELLYFIN_TYPE environment variable
+
+* fix: go back to the last step when refresh the setup page
+
+* fix: move "scanning in background" tip next to the scanning section
+
+* fix: redirect the setup page when Jellyseerr is already setup
+* **jellyfin:** Jellyfin settings now does not include a hostname. Instead it abstracted it to ip,
+port, useSsl, and urlBase. However, migration of old settings to new settings should work
+automatically.
+
+* refactor: remove console logs and use getHostname and ApiErrorCodes
+
+* fix: store req.body jellyfin settings temporarily and store only if valid
+
+This should fix the issue where settings are saved even if the url
+was invalid. Now the settings will only be saved if the url is
+valid. Sort of like a test connection.
+
+* refactor: clean up commented out code
+
+* refactor(i18n): extract translation keys
+
+* fix(auth): auth failing with jellyfin login is disabled
+
+* fix(settings): jellyfin migrations replacing the rest of the settings
+
+* fix(settings): jellyfin hostname should be carried out if hostname exists
+
+* fix(settings): merging the wrong settings source
+
+* refactor(settings): use migrator for dynamic settings migrations
+
+* refactor(settingsmigrator): settings migration handler and the migrations
+
+* test(cypress): fix cypress tests failing
+
+cypress settings were lacking some of the jobs so when the startJobs() is called when the app
+starts, it was failing to schedule the jobs where their cron timings were not specified in the
+cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options
+were added to keep cypress settings consistent with a normal user.
+
+* chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier
+
+* chore(prettier): ran formatter on cypress config to fix format check error
+
+format check locally passes on this file. However, it fails during the github actions format check.
+Therefore, json language features formatter was run instead of prettier to see if that fixes the
+issue.
+
+* test(cypress): add only missing jobs to the cypress settings
+
+* ci: attempt at trying to get formatter to pass on cypress config json file
+
+* refactor: revert the changes brought to try and fix formatter
+
+added back the rest of the cypress settings and removed cypress settings from .prettierignore
+
+* refactor(settings): better erorr logging when jellyfin connection test fails in settings page
+
+## [1.9.2](https://github.com/fallenbagel/jellyseerr/compare/v1.9.1...v1.9.2) (2024-06-13)
+
+
+### Bug Fixes
+
+* **auth:** improve login resilience with headerless fallback authentication ([#814](https://github.com/fallenbagel/jellyseerr/issues/814)) ([a9741fa](https://github.com/fallenbagel/jellyseerr/commit/a9741fa36d06710aa00d28db3dd2c29f2b0973d3))
+* **auth:** validation of ipv6/ipv4 ([#812](https://github.com/fallenbagel/jellyseerr/issues/812)) ([9aeb360](https://github.com/fallenbagel/jellyseerr/commit/9aeb3604e6498c388df1d30dd0b613ba84160fc0)), closes [#795](https://github.com/fallenbagel/jellyseerr/issues/795)
+* bypass cache-able lookups when resolving localhost ([#813](https://github.com/fallenbagel/jellyseerr/issues/813)) ([b5a0699](https://github.com/fallenbagel/jellyseerr/commit/b5a069901a9545772deaa9c491f2075261da0189))
+
+## [1.9.1](https://github.com/fallenbagel/jellyseerr/compare/v1.9.0...v1.9.1) (2024-06-12)
+
+
+### Bug Fixes
+
+* **api:** add DNS caching ([#810](https://github.com/fallenbagel/jellyseerr/issues/810)) ([46ee8a4](https://github.com/fallenbagel/jellyseerr/commit/46ee8a4ca13b026bd929b4027eb001cc74064bb8)), closes [#387](https://github.com/fallenbagel/jellyseerr/issues/387) [#657](https://github.com/fallenbagel/jellyseerr/issues/657) [#728](https://github.com/fallenbagel/jellyseerr/issues/728)
+* empty email in user settings ([#807](https://github.com/fallenbagel/jellyseerr/issues/807)) ([20863d4](https://github.com/fallenbagel/jellyseerr/commit/20863d4a8dabe78fb5c52995b5bcb2da557a804e)), closes [#803](https://github.com/fallenbagel/jellyseerr/issues/803)
+* **jellyfinscanner:** assign only 4k available badge for a 4k request instead of both badges ([#805](https://github.com/fallenbagel/jellyseerr/issues/805)) ([d31a2c3](https://github.com/fallenbagel/jellyseerr/commit/d31a2c37e639c1126b446277fa5d666d8102fef5))
+* remove the settings button of media when useless ([#809](https://github.com/fallenbagel/jellyseerr/issues/809)) ([f52939e](https://github.com/fallenbagel/jellyseerr/commit/f52939e4cdcbee94fc35165f613f6b3e21599e3c))
+
+
+### Reverts
+
+* Revert "ci: update format check command to ignore .prettierignore files (#787)" (#788) ([4757f1c](https://github.com/fallenbagel/jellyseerr/commit/4757f1c3e599304410a737c11f97db92a2bfcefd)), closes [#787](https://github.com/fallenbagel/jellyseerr/issues/787) [#788](https://github.com/fallenbagel/jellyseerr/issues/788)
+
+# [1.9.0](https://github.com/fallenbagel/jellyseerr/compare/v1.8.1...v1.9.0) (2024-05-29)
+
+
+### Bug Fixes
+
+* **api:** save user email on the first try ([#760](https://github.com/fallenbagel/jellyseerr/issues/760)) ([0bbcfdc](https://github.com/fallenbagel/jellyseerr/commit/0bbcfdc4f9ff9735f45232a2412ac8444f525de9)), closes [#227](https://github.com/fallenbagel/jellyseerr/issues/227) [#748](https://github.com/fallenbagel/jellyseerr/issues/748)
+* **api:** small errors on overseerr-api.yaml ([#721](https://github.com/fallenbagel/jellyseerr/issues/721)) ([0eea109](https://github.com/fallenbagel/jellyseerr/commit/0eea1090dfdba4333646280c84b09b0197fefa74))
+* **auth:** case-sensitive logins not updating authtokens ([#778](https://github.com/fallenbagel/jellyseerr/issues/778)) ([2bd125d](https://github.com/fallenbagel/jellyseerr/commit/2bd125d9a55d15a398ceb5f2996105a5e861b6e0))
+* **jellyfinapi:** use external api class for jellyfin api requests ([#762](https://github.com/fallenbagel/jellyseerr/issues/762)) ([650c339](https://github.com/fallenbagel/jellyseerr/commit/650c339d74d4fe85ef7f76184901e86f4eeada85)), closes [#728](https://github.com/fallenbagel/jellyseerr/issues/728) [#387](https://github.com/fallenbagel/jellyseerr/issues/387)
+* **logging:** handle media server connection refused error/toast ([#748](https://github.com/fallenbagel/jellyseerr/issues/748)) ([f486fb5](https://github.com/fallenbagel/jellyseerr/commit/f486fb5e75f9ea21456952b6a52cb841e30f3556))
+* use UTF8 encoding for webhook JSON ([#714](https://github.com/fallenbagel/jellyseerr/issues/714)) ([c0a0b9c](https://github.com/fallenbagel/jellyseerr/commit/c0a0b9c8a8b0c2eeaf3fa9159f10742baa9f6c1f))
+
+
+### Features
+
+* add Latin American Spanish translation ([#725](https://github.com/fallenbagel/jellyseerr/issues/725)) ([783fda9](https://github.com/fallenbagel/jellyseerr/commit/783fda9621aef8ffd46e5f036136de82ed502ccc)), closes [#677](https://github.com/fallenbagel/jellyseerr/issues/677)
+* add merge conflict labeler workflow ([#719](https://github.com/fallenbagel/jellyseerr/issues/719)) ([d9d07c7](https://github.com/fallenbagel/jellyseerr/commit/d9d07c705a24d5c49905066aac45a3c6a2e36a53))
+* **auth:** send real information on login ([#470](https://github.com/fallenbagel/jellyseerr/issues/470)) ([d765055](https://github.com/fallenbagel/jellyseerr/commit/d765055da83ee94546399f6348aee14d8427d462))
+* **settings:** stores jellyfin/emby server name in the settings ([#763](https://github.com/fallenbagel/jellyseerr/issues/763)) ([7a5e8d6](https://github.com/fallenbagel/jellyseerr/commit/7a5e8d69bf620c8e7bf5f284840b1a5fe757ae5f))
+
+## [1.8.1](https://github.com/fallenbagel/jellyseerr/compare/v1.8.0...v1.8.1) (2024-04-17)
+
+
+### Reverts
+
+* Revert "fix: disable seasonfolder option in sonarr for jellyfin/Emby users" (#718) ([cd0fa3e](https://github.com/fallenbagel/jellyseerr/commit/cd0fa3e2232dcb522673143f113fc382fb2ff0a3)), closes [#718](https://github.com/fallenbagel/jellyseerr/issues/718)
+
+# [1.8.0](https://github.com/fallenbagel/jellyseerr/compare/v1.7.0...v1.8.0) (2024-04-15)
+
+
+### Bug Fixes
+
+* correct width issue in datepicker of filterSliderOver ([f564cdd](https://github.com/fallenbagel/jellyseerr/commit/f564cddff4525ccebffbf304672d49c57aefe635)), closes [#415](https://github.com/fallenbagel/jellyseerr/issues/415)
+* disable seasonfolder option in sonarr for jellyfin/Emby users ([8ec8f2a](https://github.com/fallenbagel/jellyseerr/commit/8ec8f2ac5730aad3b12dcd8ed95bb553b46b399c)), closes [#126](https://github.com/fallenbagel/jellyseerr/issues/126) [#575](https://github.com/fallenbagel/jellyseerr/issues/575)
+* **embyauth:** remove the accidentally added mediaServerType change code from another PR ([#684](https://github.com/fallenbagel/jellyseerr/issues/684)) ([c2e8771](https://github.com/fallenbagel/jellyseerr/commit/c2e87714b4c4aa11bf68dcd82b76979f82990f3c))
+* ensure watchlist updates are immediately reflected ([b85d7f3](https://github.com/fallenbagel/jellyseerr/commit/b85d7f37b931735ca2ad955dccb6599bf445fc73))
+* fix german translation for "components.Discover.FilterSlideover.tmdbuservotecount" ([e032c02](https://github.com/fallenbagel/jellyseerr/commit/e032c02f5f84dc4b6b470eecb18ba2c376c55f37))
+* fix the translations for watchlist permissions and userSettings page ([8c82a61](https://github.com/fallenbagel/jellyseerr/commit/8c82a61450a7525c0e2f1b64e6939da47a7c715d))
+* **i18n:** fixed jellyfin jobs ([7eed236](https://github.com/fallenbagel/jellyseerr/commit/7eed23637ddfb10bdcb19698e7ae171f07299502))
+* **jellyfin.ts:** process virtual seasons if they have non virtual episodes ([#639](https://github.com/fallenbagel/jellyseerr/issues/639)) ([db84f65](https://github.com/fallenbagel/jellyseerr/commit/db84f6529ab285be26c96daaab065dfabf347417))
+* **jellyfinapi:** refactors jellyfin library sync to support automatic grouping and collections ([#700](https://github.com/fallenbagel/jellyseerr/issues/700)) ([3856061](https://github.com/fallenbagel/jellyseerr/commit/3856061fe1ee4d3457996586b4979ad9dd60765a)), closes [#450](https://github.com/fallenbagel/jellyseerr/issues/450) [#524](https://github.com/fallenbagel/jellyseerr/issues/524) [#256](https://github.com/fallenbagel/jellyseerr/issues/256) [#489](https://github.com/fallenbagel/jellyseerr/issues/489) [#450](https://github.com/fallenbagel/jellyseerr/issues/450) [#524](https://github.com/fallenbagel/jellyseerr/issues/524) [#515](https://github.com/fallenbagel/jellyseerr/issues/515) [#474](https://github.com/fallenbagel/jellyseerr/issues/474) [#473](https://github.com/fallenbagel/jellyseerr/issues/473)
+* **jellyfinlogin:** use externalHostname if set for forgetpassword link ([405f6bb](https://github.com/fallenbagel/jellyseerr/commit/405f6bbb7ffc390327c99dcef2cbbf9b3bc75f01)), closes [#199](https://github.com/fallenbagel/jellyseerr/issues/199) [#424](https://github.com/fallenbagel/jellyseerr/issues/424) [#212](https://github.com/fallenbagel/jellyseerr/issues/212)
+* **jellyfinscanner:** conditionally assign the jellyfinMediaId and jellyfinMediaId4k ([#686](https://github.com/fallenbagel/jellyseerr/issues/686)) ([530be42](https://github.com/fallenbagel/jellyseerr/commit/530be4272cce1b0d74d7f4156b8d794cda6ea03f)), closes [#681](https://github.com/fallenbagel/jellyseerr/issues/681)
+* **langcode:** fixes the ukranian language code ([dc67aaa](https://github.com/fallenbagel/jellyseerr/commit/dc67aaaf53eae86ba20c6c2798c92ec40962d85f)), closes [#504](https://github.com/fallenbagel/jellyseerr/issues/504)
+* nullable type for jellyfinMediaId(4k) ([#702](https://github.com/fallenbagel/jellyseerr/issues/702)) ([0900a95](https://github.com/fallenbagel/jellyseerr/commit/0900a95532501b6f4d9698de7530a771512924fc)), closes [#668](https://github.com/fallenbagel/jellyseerr/issues/668)
+* request watchlist items sequentially to prevent bypassing quota ([#3667](https://github.com/fallenbagel/jellyseerr/issues/3667)) ([b40ba07](https://github.com/fallenbagel/jellyseerr/commit/b40ba07a4de5857b8392f667038eeb0b22aa5d9a))
+* resolved issue with region selector and all regions value ([#3652](https://github.com/fallenbagel/jellyseerr/issues/3652)) ([28a2c50](https://github.com/fallenbagel/jellyseerr/commit/28a2c50495d0ce531da7f8c442bd488a54b1e84c))
+* typos on readme ([#655](https://github.com/fallenbagel/jellyseerr/issues/655)) ([eee9a02](https://github.com/fallenbagel/jellyseerr/commit/eee9a025d246c72bcd3aca753d9e49c1f8f064ea))
+* **watchlist:** added missing prop for watchlist item removal button in watchlist page ([a0ec992](https://github.com/fallenbagel/jellyseerr/commit/a0ec992028093257e9fa043622e236014f02dea3))
+* **watchlist:** discover local watchlist item display and profile local watchlist slider visibility ([3cb9494](https://github.com/fallenbagel/jellyseerr/commit/3cb9494e6210151716587d8c4b22e0a21692cf88))
+
+
+### Features
+
+* add ko language ([#3619](https://github.com/fallenbagel/jellyseerr/issues/3619)) ([9250735](https://github.com/fallenbagel/jellyseerr/commit/92507359b48db08b0066047d6505660b8c8b0b12))
+* add Peacock to Network Slider ([#3545](https://github.com/fallenbagel/jellyseerr/issues/3545)) ([0c39057](https://github.com/fallenbagel/jellyseerr/commit/0c39057ca58743697e9dcc3b678440ac3688c65a))
+* add tooltips to tautulli avatars ([#3601](https://github.com/fallenbagel/jellyseerr/issues/3601)) ([c484810](https://github.com/fallenbagel/jellyseerr/commit/c484810f965f8d04643c25c6d283dd83f4bd4a23))
+* added Letterboxd links for the external link blocks for movies ([981f5e6](https://github.com/fallenbagel/jellyseerr/commit/981f5e679c4c707e119741240a58de8bb07f9d6c))
+* check if first jellyfin user is admin ([#635](https://github.com/fallenbagel/jellyseerr/issues/635)) ([010df62](https://github.com/fallenbagel/jellyseerr/commit/010df62776191fe4c195e590df338f8d8523f55b)), closes [#610](https://github.com/fallenbagel/jellyseerr/issues/610)
+* jellyseerr makeover ([#715](https://github.com/fallenbagel/jellyseerr/issues/715)) ([0c27132](https://github.com/fallenbagel/jellyseerr/commit/0c2713213c56de342f76300d12ce01fd543d2ce3))
+* **job:** media availability support for jellyfin/emby ([#522](https://github.com/fallenbagel/jellyseerr/issues/522)) ([3eb1bb3](https://github.com/fallenbagel/jellyseerr/commit/3eb1bb3d8ff22391acb2e629bbec7b6e4b65ca95)), closes [#406](https://github.com/fallenbagel/jellyseerr/issues/406) [#193](https://github.com/fallenbagel/jellyseerr/issues/193) [#516](https://github.com/fallenbagel/jellyseerr/issues/516) [#362](https://github.com/fallenbagel/jellyseerr/issues/362) [#84](https://github.com/fallenbagel/jellyseerr/issues/84)
+* **notif:** add Pushover sound options ([#2403](https://github.com/fallenbagel/jellyseerr/issues/2403)) ([3ea5076](https://github.com/fallenbagel/jellyseerr/commit/3ea5076053359b518b1b4d537e7b61580d9275a3))
+* select default seriesType for anime ([#3627](https://github.com/fallenbagel/jellyseerr/issues/3627)) ([f628635](https://github.com/fallenbagel/jellyseerr/commit/f6286359cfd2ed93fc692aa2efda37310e02c11c)), closes [#3626](https://github.com/fallenbagel/jellyseerr/issues/3626)
+* standard series type selector ([#3628](https://github.com/fallenbagel/jellyseerr/issues/3628)) ([7bdd25e](https://github.com/fallenbagel/jellyseerr/commit/7bdd25e5a45843a3e530d3fa2b0887664b53eec8))
+* translations update from Hosted Weblate ([#3258](https://github.com/fallenbagel/jellyseerr/issues/3258)) ([e62a078](https://github.com/fallenbagel/jellyseerr/commit/e62a078298ced7dec627fb3ff9fc8f99a39d5e1b))
+* update SameSite policy of session cookie to Lax ([#3650](https://github.com/fallenbagel/jellyseerr/issues/3650)) ([c84ca43](https://github.com/fallenbagel/jellyseerr/commit/c84ca4307465af4278f3dad5cf9c2b8cbae3fada))
+
+
+### Reverts
+
+* **jellyfinapi:** reverts [#450](https://github.com/fallenbagel/jellyseerr/issues/450) as it broke library sync support for local accounts using LDAP ([b5acc09](https://github.com/fallenbagel/jellyseerr/commit/b5acc09ba98e2dd9b61e6b78721e4dd9f42a996c)), closes [#489](https://github.com/fallenbagel/jellyseerr/issues/489)
+
+# [1.7.0](https://github.com/fallenbagel/jellyseerr/compare/v1.6.0...v1.7.0) (2023-09-14)
+
+
+### Bug Fixes
+
+* adjust the plex watchlist sync schedule to have fuzziness ([#3502](https://github.com/fallenbagel/jellyseerr/issues/3502)) ([2c3f533](https://github.com/fallenbagel/jellyseerr/commit/2c3f5330764492e1323afd2d1f25e28ad78a2f2f))
+* handle issue causing incorrect media to change to unknown ([#3516](https://github.com/fallenbagel/jellyseerr/issues/3516)) ([83b008c](https://github.com/fallenbagel/jellyseerr/commit/83b008c8391459bd02dc74bcdb0d8caf27207bdf))
+* improved handling of edge case that could cause availability sync to fail ([#3497](https://github.com/fallenbagel/jellyseerr/issues/3497)) ([d0836ce](https://github.com/fallenbagel/jellyseerr/commit/d0836ce0efd55fccf2546087a0c4f94f7cb2e82a))
+* Include all defaults in payload ([#3538](https://github.com/fallenbagel/jellyseerr/issues/3538)) ([cb63bf2](https://github.com/fallenbagel/jellyseerr/commit/cb63bf217b9e8810a5210b4bf475b2a96583cc84))
+* multiple notifications for available media ([048fa96](https://github.com/fallenbagel/jellyseerr/commit/048fa967f2e5b23831ac9917c703934c50ef75f0))
+* repeat notifications for available 4k media ([30361f2](https://github.com/fallenbagel/jellyseerr/commit/30361f2ab751d9a882a9120e0f3df28dc42cc2cd))
+* resolved issue with create slider causing incorrect form submission ([#3514](https://github.com/fallenbagel/jellyseerr/issues/3514)) ([a761b7d](https://github.com/fallenbagel/jellyseerr/commit/a761b7dd35a5bd61bb4eb0275b75d1e0977e6a2d))
+* resolved user access check issue ([#3551](https://github.com/fallenbagel/jellyseerr/issues/3551)) ([2816c66](https://github.com/fallenbagel/jellyseerr/commit/2816c66300bf870d493c0665b0e984d60f707dfd))
+* **server/api/jellyfin.ts:** use /Library/VirtualFolders Jellyfin API call to fetch Jellyfin libs ([8685f57](https://github.com/fallenbagel/jellyseerr/commit/8685f5796a99d9700146bae9892319db10508d68)), closes [#256](https://github.com/fallenbagel/jellyseerr/issues/256)
+* **statusbadge:** handle missing season/episode number ([#3526](https://github.com/fallenbagel/jellyseerr/issues/3526)) ([01de972](https://github.com/fallenbagel/jellyseerr/commit/01de972a8fe2ea3c18d5b2f426d01b5b14d142d4))
+* **tautulli:** only test connection if hostname is defined ([#3573](https://github.com/fallenbagel/jellyseerr/issues/3573)) ([f7b4dfc](https://github.com/fallenbagel/jellyseerr/commit/f7b4dfcac472d08c54779a14fc1ad3c90927df26))
+* **ui:** corrected issues icon color ([#3498](https://github.com/fallenbagel/jellyseerr/issues/3498)) ([c1a47bd](https://github.com/fallenbagel/jellyseerr/commit/c1a47bd9de332cb4925974690f5a33448b5cc2e6))
+
+
+### Features
+
+* **rating:** added IMDB Radarr proxy ([#3496](https://github.com/fallenbagel/jellyseerr/issues/3496)) ([b4191f9](https://github.com/fallenbagel/jellyseerr/commit/b4191f9c65b7ff08764e61d18e7a75bc8d4b3325))
+
+# [1.6.0](https://github.com/fallenbagel/jellyseerr/compare/v1.5.0...v1.6.0) (2023-08-04)
+
+
+### Bug Fixes
+
+* availability sync file detection ([#3371](https://github.com/fallenbagel/jellyseerr/issues/3371)) ([7522aa3](https://github.com/fallenbagel/jellyseerr/commit/7522aa31743b169c903ebdf9d4d698645d27514c))
+* corrected initial fallback data load on details page ([#3395](https://github.com/fallenbagel/jellyseerr/issues/3395)) ([4bd8764](https://github.com/fallenbagel/jellyseerr/commit/4bd87647d0551c20e13589a62690a6f3e5ad8ff7))
+* correctly load series fallback modal with sonarr v4 ([#3451](https://github.com/fallenbagel/jellyseerr/issues/3451)) ([e051b1d](https://github.com/fallenbagel/jellyseerr/commit/e051b1dfea9c9320cc9dd420c475ae74cff0d901))
+* **deps:** update all non-major dependencies ([#3223](https://github.com/fallenbagel/jellyseerr/issues/3223)) ([f5191ad](https://github.com/fallenbagel/jellyseerr/commit/f5191aded680357522a65bbdcc40d162b8fbf594))
+* error deleting users with over 1000 requests ([#3376](https://github.com/fallenbagel/jellyseerr/issues/3376)) ([ac77b03](https://github.com/fallenbagel/jellyseerr/commit/ac77b037d5fb0c54f5edf4b29d04adb57aef388f))
+* external url regex is now consistent with internal url ([33ec443](https://github.com/fallenbagel/jellyseerr/commit/33ec4436fb82e1eb1bc97dd650088c27785e9d94))
+* externalLinkBlock ([46cd4d0](https://github.com/fallenbagel/jellyseerr/commit/46cd4d01d9a3cf17d79350c5e678202820272299))
+* fix regex for internal url to use a more effecient one ([e848386](https://github.com/fallenbagel/jellyseerr/commit/e848386d10f05f157e7a6dde8847ecab50c169ac))
+* fixes RT ratings for tv shows ([#3492](https://github.com/fallenbagel/jellyseerr/issues/3492)) ([04fbd00](https://github.com/fallenbagel/jellyseerr/commit/04fbd00d4ac29045592588ef8b664d1916991e37)), closes [#3491](https://github.com/fallenbagel/jellyseerr/issues/3491)
+* **genreselector:** fix searching in Genre filter ([#3468](https://github.com/fallenbagel/jellyseerr/issues/3468)) ([d7fa35e](https://github.com/fallenbagel/jellyseerr/commit/d7fa35e066cf371797aaa46ca464aa531ba8fb35))
+* handle search results with collections ([#3393](https://github.com/fallenbagel/jellyseerr/issues/3393)) ([70b1540](https://github.com/fallenbagel/jellyseerr/commit/70b1540ae23e83e01013856a9e06ad39e600922d))
+* lock body scroll when using webkit ([#3399](https://github.com/fallenbagel/jellyseerr/issues/3399)) ([c27f960](https://github.com/fallenbagel/jellyseerr/commit/c27f96096ac8cc6c387f9d1dde5b263576ac2132))
+* **logs:** jellyfin auth error now has the severity warn consistent with local login ([cc041b5](https://github.com/fallenbagel/jellyseerr/commit/cc041b5e0aa2b67573edba5919772b77a5111162)), closes [#224](https://github.com/fallenbagel/jellyseerr/issues/224)
+* make a (shallow) copy of radarr/sonarr tags into a request before adding user tags ([#3485](https://github.com/fallenbagel/jellyseerr/issues/3485)) ([48f7666](https://github.com/fallenbagel/jellyseerr/commit/48f76662d5c08156f1da3f47e216c5f02668f64b))
+* **ui:** corrected default badge hover opacity ([#3369](https://github.com/fallenbagel/jellyseerr/issues/3369)) ([a4d07f5](https://github.com/fallenbagel/jellyseerr/commit/a4d07f5afab613317d96c9c6e9b47157a5a28986))
+* **ui:** corrected mobile menu spacing in collection details ([#3432](https://github.com/fallenbagel/jellyseerr/issues/3432)) ([77a33cb](https://github.com/fallenbagel/jellyseerr/commit/77a33cb74d744bb747b791785799b632af8c7862))
+* **ui:** Make play symbol white ([1fe4bb8](https://github.com/fallenbagel/jellyseerr/commit/1fe4bb8a0415a72791ced75a2fba1027287398d5))
+* **ui:** Resize Emby icon and add margins ([ad69d67](https://github.com/fallenbagel/jellyseerr/commit/ad69d6715e976630092bfbbb1843886523551014))
+* **watchlist:** add validation for creation request ([03316c6](https://github.com/fallenbagel/jellyseerr/commit/03316c642d1ecf89753789af08caf6e3aac80113))
+* **watchlist:** fix github code scanning ([c08897b](https://github.com/fallenbagel/jellyseerr/commit/c08897bdc1cff65862c62347572bbbd01b6c36ac))
+
+
+### Features
+
+* **add watchlist:** adding midding functionality from overserr ([5f1c10d](https://github.com/fallenbagel/jellyseerr/commit/5f1c10d50aaa430bcda96218ef2cc12a0eb926f3))
+* adds streaming services custom slider ([#3361](https://github.com/fallenbagel/jellyseerr/issues/3361)) ([2520d8f](https://github.com/fallenbagel/jellyseerr/commit/2520d8f739abfde608f3ef66a9fbe6b7b5c6647a))
+* auto tagging requested media with username ([#3338](https://github.com/fallenbagel/jellyseerr/issues/3338)) ([24f268b](https://github.com/fallenbagel/jellyseerr/commit/24f268b6cb67d9a8d8675cd6e09dd83a7f499add))
+* **discover:** support filtering by tmdb user vote count on discover page ([#3407](https://github.com/fallenbagel/jellyseerr/issues/3407)) ([aa84977](https://github.com/fallenbagel/jellyseerr/commit/aa849776809dfe891e67ff4db6861ef44df1a774))
+* **settings:** add internal url to jellyfin settings form ([0a30cd3](https://github.com/fallenbagel/jellyseerr/commit/0a30cd356d217a39546c016cc8bfa6ff6ad75e3e)), closes [#194](https://github.com/fallenbagel/jellyseerr/issues/194)
+* **src/components/externallinkblock/index.tsx:** support Emby icon ([672061c](https://github.com/fallenbagel/jellyseerr/commit/672061cd646c97c9954790c8e50eac88ea2666e9))
+* **tooltip:** email tooltip now appears when hovered over info icon ([cd7930e](https://github.com/fallenbagel/jellyseerr/commit/cd7930eef98451a781e5c9dc5ec223600a379f42))
+* translations update ([47287c3](https://github.com/fallenbagel/jellyseerr/commit/47287c368885d14bd1a56e3e8318ce22dd0f6ddf)), closes [#381](https://github.com/fallenbagel/jellyseerr/issues/381)
+* **watchlist:** add translation for en ([b7e3d28](https://github.com/fallenbagel/jellyseerr/commit/b7e3d285ed35b623062eceb0d99035cafbf075a6))
+
+# [1.5.0](https://github.com/fallenbagel/jellyseerr/compare/v1.4.1...v1.5.0) (2023-04-20)
+
+
+### Bug Fixes
+
+* add better checks on 4k detection of series ([bc9017f](https://github.com/fallenbagel/jellyseerr/commit/bc9017f54d84ec24c4d74d38e1b4e24219425d41))
+* added a refresh interval if download status is in progress ([#3275](https://github.com/fallenbagel/jellyseerr/issues/3275)) ([1e2c6f4](https://github.com/fallenbagel/jellyseerr/commit/1e2c6f46ab66c836f321b5d8e34f1e8124c0b542))
+* **build:** increase threshold for amount of data to be fetched when SSR'ing ([#3320](https://github.com/fallenbagel/jellyseerr/issues/3320)) ([d7b83d2](https://github.com/fallenbagel/jellyseerr/commit/d7b83d22cee3d20db564cc0564d42802b02327e3))
+* disable availability sync temporarily ([2e5cf22](https://github.com/fallenbagel/jellyseerr/commit/2e5cf226265686012329248e7f729fec324c3deb))
+* hide remove button when default service is not configured ([7d4455b](https://github.com/fallenbagel/jellyseerr/commit/7d4455ba6bfd12e2730f7085cbb87df246f01d22))
+* **jellyfin scan:** temporary workaround fix for jellyfin scan when display specials within season ([38fb66d](https://github.com/fallenbagel/jellyseerr/commit/38fb66d31e41232c01898d0d362af8338eb7b960)), closes [#215](https://github.com/fallenbagel/jellyseerr/issues/215) [#176](https://github.com/fallenbagel/jellyseerr/issues/176) [#246](https://github.com/fallenbagel/jellyseerr/issues/246)
+* lint issues ([bcd2bb7](https://github.com/fallenbagel/jellyseerr/commit/bcd2bb7c96810f5a6932f42468a628d2db1bc771))
+* logger was set to info for the wrong logs ([#3354](https://github.com/fallenbagel/jellyseerr/issues/3354)) ([c36a4ba](https://github.com/fallenbagel/jellyseerr/commit/c36a4ba2b8df05873f5dfd0946a9bc3dc4ecfd1d))
+* remove unnecessary parenthesis from api key generation ([#3336](https://github.com/fallenbagel/jellyseerr/issues/3336)) ([6bd3f01](https://github.com/fallenbagel/jellyseerr/commit/6bd3f015d65507efca60279007bd2b86ee860643))
+* **snapcraft:** use the correct config folder for image cache ([#3302](https://github.com/fallenbagel/jellyseerr/issues/3302)) ([c93467b](https://github.com/fallenbagel/jellyseerr/commit/c93467b3acf2c256324297e7e8f21e9944005dd4))
+* **ui:** hide mini status badge if non-4K media status is unknown ([#3346](https://github.com/fallenbagel/jellyseerr/issues/3346)) ([50f06da](https://github.com/fallenbagel/jellyseerr/commit/50f06dabbffc693f0843584a64d1d96e77982820))
+* **ui:** hide search bar behind slideover when opened ([#3348](https://github.com/fallenbagel/jellyseerr/issues/3348)) ([b3882de](https://github.com/fallenbagel/jellyseerr/commit/b3882de8930a70adb2f93a27be6370bfa1826587))
+* **ui:** prevent title cards from flickering when quickly hovering across them ([#3349](https://github.com/fallenbagel/jellyseerr/issues/3349)) ([eb5502a](https://github.com/fallenbagel/jellyseerr/commit/eb5502a16f86e37a933f6beca0678c2d228e77d5))
+* **watchlist:** correctly load more than 20 watchlist items ([#3351](https://github.com/fallenbagel/jellyseerr/issues/3351)) ([af880a6](https://github.com/fallenbagel/jellyseerr/commit/af880a6c839794b34bddcd7e0fe56353aa48ba36))
+
+
+### Features
+
+* add a button in ManageSlideOver to remove the movie and the file from Radarr/Sonarr ([2e74584](https://github.com/fallenbagel/jellyseerr/commit/2e7458457e995dd3ec6dd96035fe997646cdd446))
+* availability sync rework ([#3219](https://github.com/fallenbagel/jellyseerr/issues/3219)) ([ae38183](https://github.com/fallenbagel/jellyseerr/commit/ae3818304b2f75222d1bd223ece94f829a3b42d0)), closes [#377](https://github.com/fallenbagel/jellyseerr/issues/377)
+* full title of download item on hover with tooltip ([#3296](https://github.com/fallenbagel/jellyseerr/issues/3296)) ([33e7691](https://github.com/fallenbagel/jellyseerr/commit/33e7691b94d7d369a0a1410e434850bc51e5572e))
+
+
+### Performance Improvements
+
+* **imageproxy:** do not set cookies to image proxy so CDNs can cache images ([#3332](https://github.com/fallenbagel/jellyseerr/issues/3332)) ([966639d](https://github.com/fallenbagel/jellyseerr/commit/966639df430d32f6bfebdb16314dc4590d21caf8))
+
+## [1.4.1](https://github.com/fallenbagel/jellyseerr/compare/v1.4.0...v1.4.1) (2023-01-31)
+
+
+### Bug Fixes
+
+* pass in library type when scanning recently added items ([#3287](https://github.com/fallenbagel/jellyseerr/issues/3287)) ([8942eb8](https://github.com/fallenbagel/jellyseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
+* **ui:** air date will use UTC for timezone ([#3297](https://github.com/fallenbagel/jellyseerr/issues/3297)) ([3e43586](https://github.com/fallenbagel/jellyseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
+* **ui:** correct range slider styling in chrome ([#3299](https://github.com/fallenbagel/jellyseerr/issues/3299)) ([d954328](https://github.com/fallenbagel/jellyseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
+* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/fallenbagel/jellyseerr/issues/3298)) ([7040da1](https://github.com/fallenbagel/jellyseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
+* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/fallenbagel/jellyseerr/issues/3294)) ([9d10e6a](https://github.com/fallenbagel/jellyseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
+
+# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
+
+
+### Bug Fixes
+
+* add bg-opacity to in-progress status badges ([#3190](https://github.com/fallenbagel/jellyseerr/issues/3190)) ([68223f4](https://github.com/fallenbagel/jellyseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
+* added download status and title to request card/item error components ([#3186](https://github.com/fallenbagel/jellyseerr/issues/3186)) ([3309f77](https://github.com/fallenbagel/jellyseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
+* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/fallenbagel/jellyseerr/issues/3260)) ([d328485](https://github.com/fallenbagel/jellyseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
+* **build:** update usage of publish snap action ([#3272](https://github.com/fallenbagel/jellyseerr/issues/3272)) ([51b05cd](https://github.com/fallenbagel/jellyseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
+* changed overflow scroll to only if necessary ([#3184](https://github.com/fallenbagel/jellyseerr/issues/3184)) ([27feeea](https://github.com/fallenbagel/jellyseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
+* convert genre/studio to string in create slider ([#3201](https://github.com/fallenbagel/jellyseerr/issues/3201)) ([93afead](https://github.com/fallenbagel/jellyseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
+* correct checkbox position (again) for slider edits ([#3227](https://github.com/fallenbagel/jellyseerr/issues/3227)) ([3ba6df1](https://github.com/fallenbagel/jellyseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
+* correct grid sizing for webkit on streaming services ([#3248](https://github.com/fallenbagel/jellyseerr/issues/3248)) ([6fd11cf](https://github.com/fallenbagel/jellyseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
+* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/fallenbagel/jellyseerr/issues/3268)) ([3db010b](https://github.com/fallenbagel/jellyseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
+* correct link to correct keyword results for series ([#3208](https://github.com/fallenbagel/jellyseerr/issues/3208)) ([4e9be7a](https://github.com/fallenbagel/jellyseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
+* correct spacing between sliders ([#3225](https://github.com/fallenbagel/jellyseerr/issues/3225)) ([62e2de7](https://github.com/fallenbagel/jellyseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
+* correctly check mobile menu permissions ([#3271](https://github.com/fallenbagel/jellyseerr/issues/3271)) ([f4a22dc](https://github.com/fallenbagel/jellyseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
+* correctly restore selected streaming service filters ([#3249](https://github.com/fallenbagel/jellyseerr/issues/3249)) ([154f3e7](https://github.com/fallenbagel/jellyseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
+* create shared class to add bottom spacing ([#3269](https://github.com/fallenbagel/jellyseerr/issues/3269)) ([5d1c6f7](https://github.com/fallenbagel/jellyseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
+* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/fallenbagel/jellyseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/fallenbagel/jellyseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
+* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/fallenbagel/jellyseerr/issues/2970)) ([dd48d59](https://github.com/fallenbagel/jellyseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
+* **deps:** update dependency axios to v1 ([#3202](https://github.com/fallenbagel/jellyseerr/issues/3202)) ([421029e](https://github.com/fallenbagel/jellyseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
+* **deps:** update dependency swr to v2 ([#3212](https://github.com/fallenbagel/jellyseerr/issues/3212)) ([7b6db50](https://github.com/fallenbagel/jellyseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
+* **experimental:** use new RT API (sorta) ([#3179](https://github.com/fallenbagel/jellyseerr/issues/3179)) ([357cab8](https://github.com/fallenbagel/jellyseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
+* improve small screen layout for discover editing ([#3221](https://github.com/fallenbagel/jellyseerr/issues/3221)) ([d23b213](https://github.com/fallenbagel/jellyseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
+* include new package calendar css in build ([#3235](https://github.com/fallenbagel/jellyseerr/issues/3235)) ([c2a1a20](https://github.com/fallenbagel/jellyseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
+* issues with issues ([#3267](https://github.com/fallenbagel/jellyseerr/issues/3267)) ([fd21971](https://github.com/fallenbagel/jellyseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
+* multiple genre filtering now works ([#3282](https://github.com/fallenbagel/jellyseerr/issues/3282)) ([5076938](https://github.com/fallenbagel/jellyseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
+* prevent double encode if we are on /search endpoint ([#3238](https://github.com/fallenbagel/jellyseerr/issues/3238)) ([a343f8a](https://github.com/fallenbagel/jellyseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
+* **request:** approve request when retrying request ([#3234](https://github.com/fallenbagel/jellyseerr/issues/3234)) ([b515701](https://github.com/fallenbagel/jellyseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
+* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/fallenbagel/jellyseerr/issues/3244)) ([cb65074](https://github.com/fallenbagel/jellyseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
+* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/fallenbagel/jellyseerr/issues/3226)) ([2eebb7f](https://github.com/fallenbagel/jellyseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
+* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/fallenbagel/jellyseerr/issues/3206)) ([9d3446d](https://github.com/fallenbagel/jellyseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
+* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/fallenbagel/jellyseerr/issues/3241)) ([3aefddd](https://github.com/fallenbagel/jellyseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
+* series displayed an empty season with series list/request modal ([#3147](https://github.com/fallenbagel/jellyseerr/issues/3147)) ([2179637](https://github.com/fallenbagel/jellyseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
+* tooltip shows properly if not in progress ([#3185](https://github.com/fallenbagel/jellyseerr/issues/3185)) ([6face8c](https://github.com/fallenbagel/jellyseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
+* **ui:** series first air date sorting ([#3283](https://github.com/fallenbagel/jellyseerr/issues/3283)) ([374c78c](https://github.com/fallenbagel/jellyseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
+* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/fallenbagel/jellyseerr/issues/3210)) ([042a1a9](https://github.com/fallenbagel/jellyseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
+
+
+### Features
+
+* add discover customization ([#3182](https://github.com/fallenbagel/jellyseerr/issues/3182)) ([cd35748](https://github.com/fallenbagel/jellyseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
+* add keywords to movie/series detail pages ([#3204](https://github.com/fallenbagel/jellyseerr/issues/3204)) ([e084649](https://github.com/fallenbagel/jellyseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
+* add streaming services filter ([#3247](https://github.com/fallenbagel/jellyseerr/issues/3247)) ([1154156](https://github.com/fallenbagel/jellyseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
+* discover inline customization ([#3220](https://github.com/fallenbagel/jellyseerr/issues/3220)) ([8bd10b5](https://github.com/fallenbagel/jellyseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
+* discover overhaul (filters!) ([#3232](https://github.com/fallenbagel/jellyseerr/issues/3232)) ([dd00e48](https://github.com/fallenbagel/jellyseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
+* discover slider edit arrow buttons for reordering ([#3259](https://github.com/fallenbagel/jellyseerr/issues/3259)) ([da00d45](https://github.com/fallenbagel/jellyseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
+* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/fallenbagel/jellyseerr/issues/3030)) ([0d8b390](https://github.com/fallenbagel/jellyseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
+* new mobile menu ([#3251](https://github.com/fallenbagel/jellyseerr/issues/3251)) ([fcbca17](https://github.com/fallenbagel/jellyseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
+* translations update from Hosted Weblate ([#3218](https://github.com/fallenbagel/jellyseerr/issues/3218)) ([5940ff7](https://github.com/fallenbagel/jellyseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
+* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/fallenbagel/jellyseerr/issues/3086)) ([a672b32](https://github.com/fallenbagel/jellyseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
+* **ui:** request card progress bar ([#3123](https://github.com/fallenbagel/jellyseerr/issues/3123)) ([03853a1](https://github.com/fallenbagel/jellyseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
+
+# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
+
+### Bug Fixes
+
+- added deep links to issues and status badges ([#3065](https://github.com/fallenbagel/jellyseerr/issues/3065)) ([bfe56c3](https://github.com/fallenbagel/jellyseerr/commit/bfe56c347073001795b1c3e917eb7a5afcc4462c))
+- **api:** handle auth for accounts where the plex id may have been set to null ([#3125](https://github.com/fallenbagel/jellyseerr/issues/3125)) ([15e2469](https://github.com/fallenbagel/jellyseerr/commit/15e246929bdbc2b7b5bdab7a84bd7882b79d5cb1))
+- **api:** ignore Music,Books,Photos,MusicVideo libraries ([d9ca3c6](https://github.com/fallenbagel/jellyseerr/commit/d9ca3c6e52c118698ca71021217f6ca409e71974))
+- count combined episodes ([64339e5](https://github.com/fallenbagel/jellyseerr/commit/64339e5f0374f8490e685e5c086e088bb7fd737e))
+- improved PTR scrolling performance ([#3095](https://github.com/fallenbagel/jellyseerr/issues/3095)) ([07ec3ef](https://github.com/fallenbagel/jellyseerr/commit/07ec3efbcaf669de7ccde4421c1112bfd23675d6))
+- **locale:** fix the duplicated wording in the Clear Media Warning message ([7e20c7c](https://github.com/fallenbagel/jellyseerr/commit/7e20c7cb78a44c32ab8a5f21203e285f23f402ab))
+- **ui:** adds mediaServerName to statusBadge and manageSlideOver ([d0cdce9](https://github.com/fallenbagel/jellyseerr/commit/d0cdce9e90fba642d2bf934a4266e1421424bc73)), closes [#254](https://github.com/fallenbagel/jellyseerr/issues/254)
+- update API docs to allow 'all' seasons value ([#3073](https://github.com/fallenbagel/jellyseerr/issues/3073)) ([1dfa943](https://github.com/fallenbagel/jellyseerr/commit/1dfa9431a95e7e2a1843746c2473d8a06f03e184))
+
+### Features
+
+- **api:** adds support for Mixed Libraries ([ba82ece](https://github.com/fallenbagel/jellyseerr/commit/ba82ecec5c994e79d7c9b658372041522b58a120)), closes [#95](https://github.com/fallenbagel/jellyseerr/issues/95)
+- custom image proxy ([#3056](https://github.com/fallenbagel/jellyseerr/issues/3056)) ([500cd1f](https://github.com/fallenbagel/jellyseerr/commit/500cd1f872942923d2b9c3b835e6329e335d4a3f))
+- **lang:** add Croatian display language ([#3041](https://github.com/fallenbagel/jellyseerr/issues/3041)) ([64aab6d](https://github.com/fallenbagel/jellyseerr/commit/64aab6dd8240e191026512733b34cc046b6e508a))
+
+## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06)
+
+### Bug Fixes
+
+- **auth:** resolve local/password authentication issues ([#2677](https://github.com/sct/overseerr/issues/2677)) ([b75fc7b](https://github.com/sct/overseerr/commit/b75fc7b2384ce760432620faaa92277dcd42b8e1))
+
+# [1.29.0](https://github.com/sct/overseerr/compare/v1.28.0...v1.29.0) (2022-04-01)
+
+### Bug Fixes
+
+- add Discord ID setting to general user settings page ([#2406](https://github.com/sct/overseerr/issues/2406)) ([eff665e](https://github.com/sct/overseerr/commit/eff665ef4b688aac881408790304b77bd9a31ddb))
+- address unhandled promise rejections & bump node to v16.13 ([#2398](https://github.com/sct/overseerr/issues/2398)) ([8cba486](https://github.com/sct/overseerr/commit/8cba486249fed88232e93a688c8bfe0f6179c589))
+- **css:** rename form-input to form-input-area ([#2613](https://github.com/sct/overseerr/issues/2613)) ([086f0b6](https://github.com/sct/overseerr/commit/086f0b6ce23f607d20c2cec3c73b2e4d1ce9b426))
+- **email:** enclose PGP encryption logic in try/catch ([#2519](https://github.com/sct/overseerr/issues/2519)) ([a76b608](https://github.com/sct/overseerr/commit/a76b608ab796944c0c660e3296a7aca6615d69f3))
+- **frontend:** disable autocomplete on search field ([#2592](https://github.com/sct/overseerr/issues/2592)) ([82d1617](https://github.com/sct/overseerr/commit/82d16177bf763fe8097b4aae326793e3e21e847d))
+- **frontend:** theme-color meta tag ([#2420](https://github.com/sct/overseerr/issues/2420)) ([ff28c9b](https://github.com/sct/overseerr/commit/ff28c9bfebf4a930e2542ee3b3c35f8af4e1b97e))
+- **frontend:** various fixes ([#2524](https://github.com/sct/overseerr/issues/2524)) ([c3dbd0d](https://github.com/sct/overseerr/commit/c3dbd0d6913946e0e1b5308edfbb5ca744740223))
+- **lang:** rename 'Media' notification types for clarity ([#2400](https://github.com/sct/overseerr/issues/2400)) ([399b037](https://github.com/sct/overseerr/commit/399b0379186ed34dcc436bd95330fd1a05fef4b3))
+- **lang:** translations update from Hosted Weblate ([#2625](https://github.com/sct/overseerr/issues/2625)) ([19cdedd](https://github.com/sct/overseerr/commit/19cdedd2a6656b1a852e1cc653bbdb140e978b51))
+- **lang:** translations update from Hosted Weblate ([#2639](https://github.com/sct/overseerr/issues/2639)) ([418a533](https://github.com/sct/overseerr/commit/418a533588bbbdbbbb4caee1ef91d57c1ca35717))
+- **logs:** handle log message nested extra properties ([#2459](https://github.com/sct/overseerr/issues/2459)) ([d777940](https://github.com/sct/overseerr/commit/d7779408d162949b2eafcacefc8eabe53fae229f))
+- **notif:** duplicate notification check logic ([#2424](https://github.com/sct/overseerr/issues/2424)) ([10651ba](https://github.com/sct/overseerr/commit/10651baa675993f7109989bbac67f54661c8693f))
+- **notif:** show event in pop up notification for slack ([#2413](https://github.com/sct/overseerr/issues/2413)) ([d4438c8](https://github.com/sct/overseerr/commit/d4438c82e3753c9b29b6269ad406d263b3fcef4c)), closes [#2408](https://github.com/sct/overseerr/issues/2408)
+- **plex:** correctly generate uuid for safari ([#2614](https://github.com/sct/overseerr/issues/2614)) ([d06f2cd](https://github.com/sct/overseerr/commit/d06f2cdb08bfa6f05cf7cec2c408a258fa926b09))
+- **plex:** find TV series in addition to movies from IMDb IDs ([#1830](https://github.com/sct/overseerr/issues/1830)) ([30644f6](https://github.com/sct/overseerr/commit/30644f65ea2e8437676422ae0b083c642a836887))
+- **plex:** include 'Overseerr' in X-Plex-Device-Name header ([#2635](https://github.com/sct/overseerr/issues/2635)) ([d4f9650](https://github.com/sct/overseerr/commit/d4f9650cd07704a97f8b591b7de7351c1e85b825))
+- **plex:** use unique client identifier ([#2602](https://github.com/sct/overseerr/issues/2602)) ([648b346](https://github.com/sct/overseerr/commit/648b346cbe5a941c7e1ec4ddfb276fb0e27ed502))
+- **plex:** user import ([#2442](https://github.com/sct/overseerr/issues/2442)) ([86dff12](https://github.com/sct/overseerr/commit/86dff12cdeef6dca92527dd31757a3a4c7f921bf))
+- **radarr:** correctly check for existing movies ([#2490](https://github.com/sct/overseerr/issues/2490)) ([5d4b06b](https://github.com/sct/overseerr/commit/5d4b06bbcc6cf6d328f6b4a86c4c0f9b0f3aff3e))
+- **radarr:** remove PreDB minimum availability option ([#2386](https://github.com/sct/overseerr/issues/2386)) ([3e5eb4e](https://github.com/sct/overseerr/commit/3e5eb4e148a9f88b871abc4ee1784b870f691534))
+- **requests:** check for existing media of same type when requesting ([#2445](https://github.com/sct/overseerr/issues/2445)) ([eb9ca2e](https://github.com/sct/overseerr/commit/eb9ca2e86f3be3f4ff8ee2e7c4aecdf337d8976d))
+- **sonarr:** monitor existing series upon request approval ([#2553](https://github.com/sct/overseerr/issues/2553)) ([aa062d9](https://github.com/sct/overseerr/commit/aa062d921c425d4b64bfdb28a5f102b0c92f7d87))
+- **sonarr:** only scan seasons that exist in TMDb ([#2523](https://github.com/sct/overseerr/issues/2523)) ([6168185](https://github.com/sct/overseerr/commit/61681857b123802aaeff02a8f61b1ba046c5d333))
+- **tautulli:** fetch additional user history as necessary to return 20 unique media ([#2446](https://github.com/sct/overseerr/issues/2446)) ([7d19de6](https://github.com/sct/overseerr/commit/7d19de6a4af6297be18140ca59402b40f7bbb30b))
+
+### Features
+
+- **about:** show config directory ([#2600](https://github.com/sct/overseerr/issues/2600)) ([0c7373c](https://github.com/sct/overseerr/commit/0c7373c7e89a4ff717efaa7d6a5854f7ccd6a8d3))
+- **api:** add additional request counts ([#2426](https://github.com/sct/overseerr/issues/2426)) ([2535edc](https://github.com/sct/overseerr/commit/2535edcc7fd6ec66fd45ad754c03929f1fe94871))
+- **discord:** add 'Enable Mentions' setting ([#1779](https://github.com/sct/overseerr/issues/1779)) ([5f7538a](https://github.com/sct/overseerr/commit/5f7538ae2bf9c6e2feea385cc299bd08df071218))
+- **frontend:** open media management slideover on status badge click ([#2407](https://github.com/sct/overseerr/issues/2407)) ([1f5785d](https://github.com/sct/overseerr/commit/1f5785d6c53b2ca2da67a8ccee72165c052c61a1))
+- **lang:** add Albanian display language ([#2605](https://github.com/sct/overseerr/issues/2605)) ([3d32462](https://github.com/sct/overseerr/commit/3d32462f50b4ced0d9205b79003c35d6d1c948a3))
+- **lang:** translations update from Hosted Weblate ([#2379](https://github.com/sct/overseerr/issues/2379)) ([bd93168](https://github.com/sct/overseerr/commit/bd93168ba1ed650baf4024569bb6a76811a99820))
+- **lang:** translations update from Hosted Weblate ([#2389](https://github.com/sct/overseerr/issues/2389)) ([d2241a4](https://github.com/sct/overseerr/commit/d2241a41877d126a802fc53c925d258af31f34fd))
+- **lang:** translations update from Hosted Weblate ([#2404](https://github.com/sct/overseerr/issues/2404)) ([1b29b15](https://github.com/sct/overseerr/commit/1b29b15d7c9a7ec918cb59116d60e1ae2e797dc4))
+- **lang:** translations update from Hosted Weblate ([#2405](https://github.com/sct/overseerr/issues/2405)) ([879df20](https://github.com/sct/overseerr/commit/879df20022c8c5d9b32858ac5499d3e4369fc064))
+- **lang:** translations update from Hosted Weblate ([#2414](https://github.com/sct/overseerr/issues/2414)) ([88536b1](https://github.com/sct/overseerr/commit/88536b1f9d6e8c1a11e1adf91b85bab4f34b751c))
+- **lang:** translations update from Hosted Weblate ([#2425](https://github.com/sct/overseerr/issues/2425)) ([e9d4b63](https://github.com/sct/overseerr/commit/e9d4b6327b50a005ee6c2c3292b6f107e90fc50c))
+- **lang:** translations update from Hosted Weblate ([#2428](https://github.com/sct/overseerr/issues/2428)) ([f8b1bcc](https://github.com/sct/overseerr/commit/f8b1bccda44371bb6f3f8f4ceeab900b1df3de31))
+- **lang:** translations update from Hosted Weblate ([#2436](https://github.com/sct/overseerr/issues/2436)) ([99c0407](https://github.com/sct/overseerr/commit/99c04072e9f7be8191f25cbcfd5103017b8796eb))
+- **lang:** translations update from Hosted Weblate ([#2452](https://github.com/sct/overseerr/issues/2452)) ([b5bd6ee](https://github.com/sct/overseerr/commit/b5bd6ee78f3d4aa14f0c440d1f2a8323dccfa399))
+- **lang:** translations update from Hosted Weblate ([#2457](https://github.com/sct/overseerr/issues/2457)) ([92b2d32](https://github.com/sct/overseerr/commit/92b2d32d2e1e1d319410a9e357e1304065a77598))
+- **lang:** translations update from Hosted Weblate ([#2489](https://github.com/sct/overseerr/issues/2489)) ([ec08fa6](https://github.com/sct/overseerr/commit/ec08fa67934715ff4a4d618d5b9ff97853913b78))
+- **lang:** translations update from Hosted Weblate ([#2508](https://github.com/sct/overseerr/issues/2508)) ([9f4ae34](https://github.com/sct/overseerr/commit/9f4ae34da76707a40e2c89a50c722ffa1c0327c0))
+- **lang:** translations update from Hosted Weblate ([#2531](https://github.com/sct/overseerr/issues/2531)) ([54b32eb](https://github.com/sct/overseerr/commit/54b32ebfd6b2eb6aeeea98c25939166eda8cc17f))
+- **lang:** translations update from Hosted Weblate ([#2541](https://github.com/sct/overseerr/issues/2541)) ([4549ed3](https://github.com/sct/overseerr/commit/4549ed389e4f25c0946dc01526387e5ac000c3cf))
+- **lang:** translations update from Hosted Weblate ([#2611](https://github.com/sct/overseerr/issues/2611)) ([81c75c8](https://github.com/sct/overseerr/commit/81c75c800edf6d36a1082a291ef7e308f338d005))
+- **lang:** translations update from Hosted Weblate ([#2629](https://github.com/sct/overseerr/issues/2629)) ([1d0cbd2](https://github.com/sct/overseerr/commit/1d0cbd2e761072be0b4b3de461397ad9f9f681f3))
+- **lang:** translations update from Hosted Weblate ([#2645](https://github.com/sct/overseerr/issues/2645)) ([341e3b8](https://github.com/sct/overseerr/commit/341e3b8f0657e09f53ad0b813b051290947343c0))
+- **logs:** use separate json file to parse logs for log viewer ([#2399](https://github.com/sct/overseerr/issues/2399)) ([ce31bef](https://github.com/sct/overseerr/commit/ce31bef8a125c5492f2a1cfef0dcf3d8a4e9ee11))
+- **notif:** add Gotify agent ([#2196](https://github.com/sct/overseerr/issues/2196)) ([e0b6abe](https://github.com/sct/overseerr/commit/e0b6abe4796f5a324c0ff78cff317fcaead671f1)), closes [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2077](https://github.com/sct/overseerr/issues/2077) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2077](https://github.com/sct/overseerr/issues/2077) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183)
+- **notif:** add Pushbullet channel tag ([#2198](https://github.com/sct/overseerr/issues/2198)) ([f9200b7](https://github.com/sct/overseerr/commit/f9200b7977208f9b8267ce3a74bd8a86d6f28f7b))
+- **plex:** selective user import ([#2188](https://github.com/sct/overseerr/issues/2188)) ([9cb97db](https://github.com/sct/overseerr/commit/9cb97db13ced5df2dc595cd9033470b1a0750093))
+- **search:** filter search results by year ([#2460](https://github.com/sct/overseerr/issues/2460)) ([72c825d](https://github.com/sct/overseerr/commit/72c825d2a5109688bcc1991a30249284bf281500))
+- **search:** search by id ([#2082](https://github.com/sct/overseerr/issues/2082)) ([b31cdbf](https://github.com/sct/overseerr/commit/b31cdbf074d5dbecbbf6da135a9b686aea9e3c0e))
+- Tautulli integration ([#2230](https://github.com/sct/overseerr/issues/2230)) ([0842c23](https://github.com/sct/overseerr/commit/0842c233d0fc56d44824cad18749492cd52cbed5))
+- **tautulli:** validate upon saving settings ([#2511](https://github.com/sct/overseerr/issues/2511)) ([1dc900d](https://github.com/sct/overseerr/commit/1dc900d5ce9689d179c9d2f554abc74ca50bd9cb))
+- **ui:** add trakt external link ([#2367](https://github.com/sct/overseerr/issues/2367)) ([4e56bae](https://github.com/sct/overseerr/commit/4e56bae98508c1a60aeb3a08560ba1c00acce7e7))
+- verify Plex server access during auth for existing users with Plex IDs ([#2458](https://github.com/sct/overseerr/issues/2458)) ([85bb30e](https://github.com/sct/overseerr/commit/85bb30e252c27047ae367491f0e5bb92a7d52605))
+
+# [1.28.0](https://github.com/sct/overseerr/compare/v1.27.0...v1.28.0) (2022-01-01)
+
+### Bug Fixes
+
+- add missing route guards to issues pages ([#2235](https://github.com/sct/overseerr/issues/2235)) ([c79dc9f](https://github.com/sct/overseerr/commit/c79dc9f70f512dbec0e3460ee78dbc9feccfbbb1))
+- allow basic HTTP auth in hostname validation ([#2307](https://github.com/sct/overseerr/issues/2307)) ([d48a7ba](https://github.com/sct/overseerr/commit/d48a7ba518f9c79d70e499037cb730eb3efe2c08))
+- **docker:** explicitly install python3 ([#2273](https://github.com/sct/overseerr/issues/2273)) [skip ci] ([f1cd087](https://github.com/sct/overseerr/commit/f1cd0878a5c74bddc864f5f8ce9e2f041bdde5ec))
+- **email:** use decrypted private key ([#2232](https://github.com/sct/overseerr/issues/2232)) ([8d29685](https://github.com/sct/overseerr/commit/8d2968572a569ed77a4d7c14ae1dc69935fa847e))
+- **frontend:** more issues-related fixes ([#2234](https://github.com/sct/overseerr/issues/2234)) ([3ec4a9c](https://github.com/sct/overseerr/commit/3ec4a9c76e1f31bee5c8801b389721bf8e5884e0))
+- **frontend:** setup page backdrops ([#2251](https://github.com/sct/overseerr/issues/2251)) ([78a8091](https://github.com/sct/overseerr/commit/78a8091bcd29a7cf50cc7c493c28710389817adf))
+- **frontend:** use consistent formatting & strings ([#2231](https://github.com/sct/overseerr/issues/2231)) ([2164471](https://github.com/sct/overseerr/commit/216447121b686b6d01a31b95ec0c8eb005f6b103))
+- handle Plex library settings migration failure gracefully ([#2254](https://github.com/sct/overseerr/issues/2254)) ([ed53810](https://github.com/sct/overseerr/commit/ed53810fb33f70722361c67d176ff4edf531ba45))
+- **issues:** only allow edit of own comments & do not allow non-admin delete of issues with comments ([#2248](https://github.com/sct/overseerr/issues/2248)) ([bba09d6](https://github.com/sct/overseerr/commit/bba09d69c1bc55c2f35db5a7986e7c935cc9619c))
+- **lang:** add missing string ([#2370](https://github.com/sct/overseerr/issues/2370)) ([d36c1d2](https://github.com/sct/overseerr/commit/d36c1d29295020efb76bac21a443b6f9049802f3))
+- **lang:** string edits ([#2229](https://github.com/sct/overseerr/issues/2229)) ([ab20c21](https://github.com/sct/overseerr/commit/ab20c21184639e1c7725f7cae96249c6fa157351))
+- **lang:** translations update from Weblate ([#2212](https://github.com/sct/overseerr/issues/2212)) ([85aec4f](https://github.com/sct/overseerr/commit/85aec4f8925746ebae9bcc99d8480b78ccfd851e))
+- **logs:** handle unexpected log messages ([#2303](https://github.com/sct/overseerr/issues/2303)) ([f284e4a](https://github.com/sct/overseerr/commit/f284e4ab978e502d2cc08e76226a8ebac91bb48f))
+- **logs:** lazily parse log message label ([#2359](https://github.com/sct/overseerr/issues/2359)) ([5af06bd](https://github.com/sct/overseerr/commit/5af06bd87226fbc6176b0c5e362824793165a34e))
+- **notif:** correct issue notif action URLs ([#2333](https://github.com/sct/overseerr/issues/2333)) ([dc7f959](https://github.com/sct/overseerr/commit/dc7f959cb422a8d89bcebc78377f1513412e542c))
+- **notif:** only send MEDIA_AVAILABLE notifications for non-declined requests ([#2343](https://github.com/sct/overseerr/issues/2343)) ([fcb0dcf](https://github.com/sct/overseerr/commit/fcb0dcf5be64bf9ca814bfe119586908922099c5))
+- **requests:** do not fail request edits if acting user lacks Manage Users permission ([#2338](https://github.com/sct/overseerr/issues/2338)) ([91bfff7](https://github.com/sct/overseerr/commit/91bfff71b7c05c9b9aad2c95282533eefbb6b2e7))
+- secure session cookie ([#2308](https://github.com/sct/overseerr/issues/2308)) ([7f330af](https://github.com/sct/overseerr/commit/7f330aff2e1d3546e8dd1a3e4b037b9beb1cc7f0))
+- **servarr:** handle baseurl error when testing connection ([#2294](https://github.com/sct/overseerr/issues/2294)) ([93b5ea2](https://github.com/sct/overseerr/commit/93b5ea20ca590996f6dc90713a76800180d0621c))
+- **servarr:** handle servaarr server being unavailable when scanning downloads ([#2358](https://github.com/sct/overseerr/issues/2358)) ([488874f](https://github.com/sct/overseerr/commit/488874fc17e4e4719e90d383b83b1e1a5217213b))
+- sort collection parts by release date ([#2368](https://github.com/sct/overseerr/issues/2368)) ([1b3797c](https://github.com/sct/overseerr/commit/1b3797cf6e6ef6b3d8c81e644382f6e3f68cfaaa))
+- **ui:** request badge styling in request list ([#2302](https://github.com/sct/overseerr/issues/2302)) ([f2375c9](https://github.com/sct/overseerr/commit/f2375c902b79dcb1f349500862775ae57ea7d406))
+
+### Features
+
+- add production countries to movie/TV detail pages ([#2170](https://github.com/sct/overseerr/issues/2170)) ([30b20df](https://github.com/sct/overseerr/commit/30b20df37a9604ba1c066f89e54a5482a09575ea))
+- add quotas, advanced options, and toggles to collection request modal ([#1742](https://github.com/sct/overseerr/issues/1742)) ([af40212](https://github.com/sct/overseerr/commit/af40212a738f8d6d9a5bf26dc20c0c87780d6020))
+- **frontend:** add Discovery+ to network slider ([#2345](https://github.com/sct/overseerr/issues/2345)) ([2ded8f5](https://github.com/sct/overseerr/commit/2ded8f5484168bd7b8f45124d9ebdd296a5708d5))
+- issues ([#2180](https://github.com/sct/overseerr/issues/2180)) ([e402c42](https://github.com/sct/overseerr/commit/e402c42aaa7d795cd724856a2e23615bb1a3695d))
+- **lang:** add Polish display language ([#2261](https://github.com/sct/overseerr/issues/2261)) ([c760cea](https://github.com/sct/overseerr/commit/c760ceaa5f36c77fa3ce320fae1b4597d2d8b976))
+- **lang:** translated using Weblate (Chinese (Traditional)) ([#2272](https://github.com/sct/overseerr/issues/2272)) ([d401e33](https://github.com/sct/overseerr/commit/d401e33249cbbca6e707479e5f0207e298ef3248))
+- **lang:** translations update from Hosted Weblate ([#2277](https://github.com/sct/overseerr/issues/2277)) ([92732fc](https://github.com/sct/overseerr/commit/92732fcb42c56242d16daab00e2d38740b92dea0))
+- **lang:** translations update from Hosted Weblate ([#2315](https://github.com/sct/overseerr/issues/2315)) ([6245be1](https://github.com/sct/overseerr/commit/6245be1e10dda67c869b59522c1290e7c100145f))
+- **lang:** translations update from Hosted Weblate ([#2320](https://github.com/sct/overseerr/issues/2320)) ([68112fa](https://github.com/sct/overseerr/commit/68112faefbd64d5c71d3eff21620767f88ccfc34))
+- **lang:** translations update from Hosted Weblate ([#2325](https://github.com/sct/overseerr/issues/2325)) ([febf067](https://github.com/sct/overseerr/commit/febf0677b880d2fed2822ce510db7cbb0826a920))
+- **lang:** translations update from Hosted Weblate ([#2336](https://github.com/sct/overseerr/issues/2336)) ([3f7ef7a](https://github.com/sct/overseerr/commit/3f7ef7af97a807ef38041f4f2642b565aa33d066))
+- **lang:** translations update from Hosted Weblate ([#2341](https://github.com/sct/overseerr/issues/2341)) ([33fe0bd](https://github.com/sct/overseerr/commit/33fe0bdd1e00da40e85b4e4b4780134b31a105d2))
+- **lang:** translations update from Hosted Weblate ([#2346](https://github.com/sct/overseerr/issues/2346)) ([50dc934](https://github.com/sct/overseerr/commit/50dc9341dd98cb2d8ef3ef6471882a5a9b060afa))
+- **lang:** translations update from Hosted Weblate ([#2364](https://github.com/sct/overseerr/issues/2364)) ([d437cc2](https://github.com/sct/overseerr/commit/d437cc25392e9c0881888371ffabc82892a1b15c))
+- **lang:** translations update from Hosted Weblate ([#2366](https://github.com/sct/overseerr/issues/2366)) ([cc2b2bc](https://github.com/sct/overseerr/commit/cc2b2bc7a8ecd89e1feb38a907596b16df9bf0fc))
+- **lang:** translations update from Hosted Weblate ([#2374](https://github.com/sct/overseerr/issues/2374)) ([b9bedac](https://github.com/sct/overseerr/commit/b9bedac7d7ba85223ecf1d9b93b96e2a490d571a))
+- **lang:** translations update from Weblate ([#2226](https://github.com/sct/overseerr/issues/2226)) ([62b3dc5](https://github.com/sct/overseerr/commit/62b3dc5471c28f4d0e4399cb3bc8bfab94cff5ea))
+- **lang:** translations update from Weblate ([#2241](https://github.com/sct/overseerr/issues/2241)) ([2b0b8e0](https://github.com/sct/overseerr/commit/2b0b8e05d9c95ff9218cea858a920a2815871186))
+- **lang:** translations update from Weblate ([#2244](https://github.com/sct/overseerr/issues/2244)) ([0828b00](https://github.com/sct/overseerr/commit/0828b008badc8b512316799a6787bb7c403658d5))
+- **lang:** translations update from Weblate ([#2247](https://github.com/sct/overseerr/issues/2247)) ([8c49309](https://github.com/sct/overseerr/commit/8c49309c35c31f7bcd0b84b0a307febc16842f68))
+- **lang:** translations update from Weblate ([#2252](https://github.com/sct/overseerr/issues/2252)) ([99d5000](https://github.com/sct/overseerr/commit/99d50004e58f6b4594df0a171f6bc668635ec50c))
+- **lang:** translations update from Weblate ([#2265](https://github.com/sct/overseerr/issues/2265)) ([b1b367a](https://github.com/sct/overseerr/commit/b1b367aac625ed3eb865832c94c2352e5a5c40f5))
+- **notif:** 4K media notifications ([#2324](https://github.com/sct/overseerr/issues/2324)) ([88a8c1a](https://github.com/sct/overseerr/commit/88a8c1aa596e1113d6da52e5e8cbe443abc6384f))
+- **notif:** add Pushbullet and Pushover agents to user notification settings ([#1740](https://github.com/sct/overseerr/issues/1740)) ([aeb7a48](https://github.com/sct/overseerr/commit/aeb7a48d72cec3fa2b857030aad3eaa0a457a896))
+- **notif:** issue notifications ([#2242](https://github.com/sct/overseerr/issues/2242)) ([c9ffac3](https://github.com/sct/overseerr/commit/c9ffac33f7c04d926f8c45295703689d42fe87af))
+- **search:** close search bar when hitting return ([#2260](https://github.com/sct/overseerr/issues/2260)) ([b423dc1](https://github.com/sct/overseerr/commit/b423dc167d12f0ba49f902876bceb2e876e35f58))
+- **ui:** allow admins to edit & approve request from advanced request modal ([#2067](https://github.com/sct/overseerr/issues/2067)) ([340f1a2](https://github.com/sct/overseerr/commit/340f1a211952bd2e8f40f0ea4622b52dbe934e85))
+
+# [1.27.0](https://github.com/sct/overseerr/compare/v1.26.1...v1.27.0) (2021-10-19)
+
+### Bug Fixes
+
+- **api:** return queried user's requests instead of own requests ([#2174](https://github.com/sct/overseerr/issues/2174)) ([0edb1f4](https://github.com/sct/overseerr/commit/0edb1f452b6ff4a49ae2bde15f7273769788cf4f))
+- **api:** use query builder for user requests endpoint ([#2119](https://github.com/sct/overseerr/issues/2119)) ([a20f395](https://github.com/sct/overseerr/commit/a20f395c94c97dd7ddbc25590f15def2c9bf13c9))
+- apply request overrides iff override & selected servers match ([#2164](https://github.com/sct/overseerr/issues/2164)) ([50ce198](https://github.com/sct/overseerr/commit/50ce198471b1a3777a183d68904bbfb39ebd4523))
+- **email:** do not attempt to display logo if app URL not configured ([#2125](https://github.com/sct/overseerr/issues/2125)) ([b3b421a](https://github.com/sct/overseerr/commit/b3b421a67408a4a48d23c15341fcdf7aaf19b25a))
+- **frontend:** notification type validation ([#2207](https://github.com/sct/overseerr/issues/2207)) ([2f204b9](https://github.com/sct/overseerr/commit/2f204b995269a53ae36f2a8733f27ae6ab70da5a))
+- **scripts:** update migration scripts ([#2208](https://github.com/sct/overseerr/issues/2208)) [skip ci] ([d0ac74e](https://github.com/sct/overseerr/commit/d0ac74ea4bbfcf3d25d30cbd422d9df1c1259a18))
+- **ui:** refinements for 'About' page ([#2173](https://github.com/sct/overseerr/issues/2173)) ([084a842](https://github.com/sct/overseerr/commit/084a842a4f9b6caaed22edbe77bc9e414bc1f387))
+
+### Features
+
+- display release dates for theatrical, digital, and physical release types ([#1492](https://github.com/sct/overseerr/issues/1492)) ([a4dca23](https://github.com/sct/overseerr/commit/a4dca2356b7605026f7bc45b691496e765c3328c))
+- dynamically fetch login screen backdrop images ([#2206](https://github.com/sct/overseerr/issues/2206)) ([3486d0b](https://github.com/sct/overseerr/commit/3486d0bf5520cbdff60bd8fd023caed76c452973))
+- **frontend:** add Hulu to network slider ([#2204](https://github.com/sct/overseerr/issues/2204)) ([1e402f7](https://github.com/sct/overseerr/commit/1e402f710b53c11855aab0abdb4b12c51c30b022))
+- **jobs:** allow modifying job schedules ([#1440](https://github.com/sct/overseerr/issues/1440)) ([82614ca](https://github.com/sct/overseerr/commit/82614ca4410782a12d65b4c0a6526ff064be1241))
+- **lang:** add Czech and Danish display languages ([#2176](https://github.com/sct/overseerr/issues/2176)) ([8d8db6c](https://github.com/sct/overseerr/commit/8d8db6cf5d98d4e498a31db339d02f8a98057c8d))
+- **lang:** translations update from Weblate ([#2101](https://github.com/sct/overseerr/issues/2101)) ([c73cf7b](https://github.com/sct/overseerr/commit/c73cf7b19cbc19e97a777c0facb9264fb0113093))
+- **lang:** translations update from Weblate ([#2179](https://github.com/sct/overseerr/issues/2179)) ([e3312ce](https://github.com/sct/overseerr/commit/e3312cef33821c8cb76a4a63bd565c78d67b3e0b))
+- **lang:** translations update from Weblate ([#2185](https://github.com/sct/overseerr/issues/2185)) ([dce10f7](https://github.com/sct/overseerr/commit/dce10f743f52cb04036e2cdaee280e26a81b253b))
+- **lang:** translations update from Weblate ([#2202](https://github.com/sct/overseerr/issues/2202)) ([492d8e3](https://github.com/sct/overseerr/commit/492d8e3daa5fb99aa9df2a18978085d5ddd581e7))
+- **lang:** translations update from Weblate ([#2210](https://github.com/sct/overseerr/issues/2210)) ([0a6ef6c](https://github.com/sct/overseerr/commit/0a6ef6cc81376f7a02f1483109be7ae4ab851c48))
+- **plex-scan:** plex scanner improvements ([#2105](https://github.com/sct/overseerr/issues/2105)) ([afda9c7](https://github.com/sct/overseerr/commit/afda9c7dc222137b0e6654a6beb4737cf2c1752e))
+- **servarr:** auto fill base url when testing service if missing ([#1995](https://github.com/sct/overseerr/issues/1995)) ([739f667](https://github.com/sct/overseerr/commit/739f667b54d8dec258b74d0cd8fd8b3b88dcf8d5))
+- **ui:** link processing/requested status badges to service URL ([#1761](https://github.com/sct/overseerr/issues/1761)) ([032c14a](https://github.com/sct/overseerr/commit/032c14a22680f62f8106943297b081b68645ce61))
+
+## [1.26.1](https://github.com/sct/overseerr/compare/v1.26.0...v1.26.1) (2021-09-20)
+
+### Bug Fixes
+
+- **rt-api:** correctly format movie urls ([4c6009b](https://github.com/sct/overseerr/commit/4c6009bc2c3ff5f657a806363e3bdf7cd83d4261))
+
+# [1.26.0](https://github.com/sct/overseerr/compare/v1.25.0...v1.26.0) (2021-09-19)
+
+### Bug Fixes
+
+- **email:** omit links when application URL is not configured ([#1806](https://github.com/sct/overseerr/issues/1806)) ([1133a34](https://github.com/sct/overseerr/commit/1133a34ffdf95c4d036be0264fe7f94f64007e8f))
+- **lang:** minor changes to password reset strings ([#1798](https://github.com/sct/overseerr/issues/1798)) ([a41245c](https://github.com/sct/overseerr/commit/a41245c703688743ec24f9b4a53e70f3340daa0f))
+- **notif:** truncate media overviews ([#1800](https://github.com/sct/overseerr/issues/1800)) ([42e45f3](https://github.com/sct/overseerr/commit/42e45f38e5ede7df0fc4bdb20a970917b2361569))
+- **plex:** do not fail to scan empty libraries ([#1771](https://github.com/sct/overseerr/issues/1771)) ([6789b87](https://github.com/sct/overseerr/commit/6789b8701cb644d9a3f1384f30b3dff707201ef7))
+- **quota:** block multi-season requests that would exceed a user's quota ([#1874](https://github.com/sct/overseerr/issues/1874)) ([8a55f85](https://github.com/sct/overseerr/commit/8a55f85d3ef14ccb83b139acb35d0746431637be))
+- **rt-api:** use rotten-tomatoes 2.0 search api for movies ([a11bb49](https://github.com/sct/overseerr/commit/a11bb49663ec345332c4dd70ddbb49ce230b5c3c))
+- **ui:** center logo on password reset pages ([#1807](https://github.com/sct/overseerr/issues/1807)) ([b8e82b5](https://github.com/sct/overseerr/commit/b8e82b5b4d3cb49ec372e3dce3cd89dff440ffd0))
+- **ui:** change sidebar breakpoint to lg ([#1972](https://github.com/sct/overseerr/issues/1972)) ([70bd9e9](https://github.com/sct/overseerr/commit/70bd9e9308b607206b60a2a36a511de6e397a3db))
+- **ui:** do not allow submission of invalid form inputs ([#1799](https://github.com/sct/overseerr/issues/1799)) ([910d00c](https://github.com/sct/overseerr/commit/910d00c19522a70125bfb5e5081a7ef4000e7f54))
+- **ui:** do not display negative remaining quota ([#1859](https://github.com/sct/overseerr/issues/1859)) ([3841fb0](https://github.com/sct/overseerr/commit/3841fb06ebe1e09250362cc6cb401fdca12eef7f))
+- **ui:** fix notifications settings buttons overflowing ([#1911](https://github.com/sct/overseerr/issues/1911)) ([0ce18b2](https://github.com/sct/overseerr/commit/0ce18b21ca547af6c083c3f248e22b7daf92aef0))
+- **ui:** sort 'Request As' user dropdown by display name ([#2099](https://github.com/sct/overseerr/issues/2099)) ([bb09f8e](https://github.com/sct/overseerr/commit/bb09f8eaf70f6d0c981f31bd5f3c8afb2fe101ab))
+- **webpush:** load user in push sub query ([#1894](https://github.com/sct/overseerr/issues/1894)) ([6f2db6a](https://github.com/sct/overseerr/commit/6f2db6a6ccf299262cf86d91acf639b921f28286))
+- correct logo filename ([#1805](https://github.com/sct/overseerr/issues/1805)) ([f95be83](https://github.com/sct/overseerr/commit/f95be832f95a68b114ff24a65ffa0ebbd71b4121))
+
+### Features
+
+- list streaming providers on movie/TV detail pages ([#1778](https://github.com/sct/overseerr/issues/1778)) ([98ece67](https://github.com/sct/overseerr/commit/98ece67655a5dffe894974e337a3603afeed0236))
+- **lang:** add Simplified Chinese display language ([#2032](https://github.com/sct/overseerr/issues/2032)) ([590ea7e](https://github.com/sct/overseerr/commit/590ea7e40460e381377b212d00869f191908b41f))
+- **lang:** translated using Weblate (German) ([#1791](https://github.com/sct/overseerr/issues/1791)) ([15f7941](https://github.com/sct/overseerr/commit/15f7941269075b7e12de8bbc0f98418af70df380))
+- **lang:** translations update from Weblate ([#1772](https://github.com/sct/overseerr/issues/1772)) ([6a75a05](https://github.com/sct/overseerr/commit/6a75a05c2348455d5374132a2574d988879d543a))
+- **lang:** translations update from Weblate ([#1796](https://github.com/sct/overseerr/issues/1796)) ([57b52fc](https://github.com/sct/overseerr/commit/57b52fc9cccd3fac93cdb68e36cf652ddbcdf86c))
+- **lang:** translations update from Weblate ([#1910](https://github.com/sct/overseerr/issues/1910)) ([fe89fd5](https://github.com/sct/overseerr/commit/fe89fd5f12460cb1b3acb09fb16b62497ef50f5f))
+- **lang:** translations update from Weblate ([#2058](https://github.com/sct/overseerr/issues/2058)) ([db42c46](https://github.com/sct/overseerr/commit/db42c4678145d2a9676aa71b6773607b696f7cea))
+- **notif:** Restyle HTML email notifications Part 2 ([#1917](https://github.com/sct/overseerr/issues/1917)) ([376149d](https://github.com/sct/overseerr/commit/376149d6ebb4db28d949391115f475afdd4e7d48))
+- **ui:** add 'show more/less...' for studios on movie details page ([#1770](https://github.com/sct/overseerr/issues/1770)) ([680ea0c](https://github.com/sct/overseerr/commit/680ea0c87a9ae143413354680c421d62bccd869d))
+- new logo, who dis? ([#1802](https://github.com/sct/overseerr/issues/1802)) ([beb5637](https://github.com/sct/overseerr/commit/beb5637d9f5c01d773eaee93035b7c195c2ae5f2))
+
+# [1.25.0](https://github.com/sct/overseerr/compare/v1.24.0...v1.25.0) (2021-06-10)
+
+### Bug Fixes
+
+- **frontend:** add missing route guards to settings pages ([#1700](https://github.com/sct/overseerr/issues/1700)) ([78fc1f7](https://github.com/sct/overseerr/commit/78fc1f7b7d9ef912077066a3605fed6237fb4c8a))
+- **locale:** set locale based on user settings upon login ([#1584](https://github.com/sct/overseerr/issues/1584)) ([f48312e](https://github.com/sct/overseerr/commit/f48312e833ed5d48c41179d0eadbc66d45486d8a))
+- **notif:** include year in Media Available notifications ([#1672](https://github.com/sct/overseerr/issues/1672)) ([11aa712](https://github.com/sct/overseerr/commit/11aa712eb0e8796874c96fbcc9b51b523108e2d4))
+- **plex:** disable library sync if Plex not configured, and disable scan if no libraries ([#1764](https://github.com/sct/overseerr/issues/1764)) ([22238fe](https://github.com/sct/overseerr/commit/22238fe4f711267d001be95942b3151c536e0c18))
+- **plex:** do not fail to import Plex users when Plex Home has managed users ([#1699](https://github.com/sct/overseerr/issues/1699)) ([310cdb3](https://github.com/sct/overseerr/commit/310cdb36df1601bca5e57f0bc796c44111b8435f))
+- **plex:** sync libraries after saving settings ([#1592](https://github.com/sct/overseerr/issues/1592)) ([9749d72](https://github.com/sct/overseerr/commit/9749d723fc0a282b291c06ee68a6e174dcec1c5b))
+- **requests:** appropriately set modifiedBy user for new requests ([#1684](https://github.com/sct/overseerr/issues/1684)) ([a3f04b3](https://github.com/sct/overseerr/commit/a3f04b3f3522d46dc65178bddd1e986426e48050))
+- **requests:** do not prevent duplicate requests if other requests are declined ([de0759c](https://github.com/sct/overseerr/commit/de0759c26a9e857e2b8d7244673625fc79ee4660))
+- **requests:** prevent duplicate movie requests ([126d866](https://github.com/sct/overseerr/commit/126d8665ee2808fc0bc37df4ca61f3e63be096e2))
+- check that application URL and email agent are configured for password reset/generation ([#1724](https://github.com/sct/overseerr/issues/1724)) ([091d66a](https://github.com/sct/overseerr/commit/091d66a1928d3c69a11eab2a789b4639b5ba9817))
+- correctly display error messages ([#1653](https://github.com/sct/overseerr/issues/1653)) ([31cb717](https://github.com/sct/overseerr/commit/31cb7176d286e706575a2dc8003df13f3e737106))
+- handle null values in User email transform ([#1712](https://github.com/sct/overseerr/issues/1712)) ([4a042f1](https://github.com/sct/overseerr/commit/4a042f12be6510ee47de3a7e025497f8d132d6a1))
+- **lang:** only set locale once at page load and move subsequent updates back into Layout ([14756f4](https://github.com/sct/overseerr/commit/14756f4b208c5b201a6e632b43e7a21c5bec6f9c)), closes [#1662](https://github.com/sct/overseerr/issues/1662)
+- **locale:** properly restore display language upon page refresh ([#1646](https://github.com/sct/overseerr/issues/1646)) ([e85d1ce](https://github.com/sct/overseerr/commit/e85d1ce94ec45d8f5d086722cfd88e0e2c5b4bb6))
+- **notifications:** default webpush notification agent to enabled for users for settings response ([7520e24](https://github.com/sct/overseerr/commit/7520e24e9287e214dd31224f1201e9b6385fd567)), closes [#1663](https://github.com/sct/overseerr/issues/1663)
+- **quotas:** do not count already-requested seasons when editing TV request ([#1649](https://github.com/sct/overseerr/issues/1649)) ([808ccf1](https://github.com/sct/overseerr/commit/808ccf1c6975f853db6dc89f4d9f1f5488dbaae3))
+- **requests:** remove requestedBy user param from existing movie request check ([#1569](https://github.com/sct/overseerr/issues/1569)) ([788f3dc](https://github.com/sct/overseerr/commit/788f3dc435ae224fcc4d4cb2890b1b9b494c64e8))
+- **sensitiveinput:** do not capture enter key input ([#1650](https://github.com/sct/overseerr/issues/1650)) ([bb8d14b](https://github.com/sct/overseerr/commit/bb8d14b5ffd840eff0c2a00e1b5d318677a5ca5f))
+- **sonarr:** do not mark media as failed if there is no season data on TVDB ([#1691](https://github.com/sct/overseerr/issues/1691)) ([0cd7fa0](https://github.com/sct/overseerr/commit/0cd7fa0f1a00d129339be13550a4f694c820a0e9))
+- **tv:** don't show duplicate air date ([#1666](https://github.com/sct/overseerr/issues/1666)) ([e1f5feb](https://github.com/sct/overseerr/commit/e1f5febe7bbf27e77b6f5d057c2c3f7e22898734))
+- **ui:** add clarification to user settings ([#1644](https://github.com/sct/overseerr/issues/1644)) ([2ef57e9](https://github.com/sct/overseerr/commit/2ef57e9b1a5b4d0a1499921f4e26b0b0712d7ded))
+- **ui:** correct horizontal overflow behavior of settings tabs ([#1667](https://github.com/sct/overseerr/issues/1667)) ([e6d5f0a](https://github.com/sct/overseerr/commit/e6d5f0abfebdc24f25d08822b57a8eb7bc48e137))
+- **ui:** hide advanced request options when there is only one choice ([#1591](https://github.com/sct/overseerr/issues/1591)) ([6b26188](https://github.com/sct/overseerr/commit/6b26188d888a1f80bd36a1968e41333bab2af794))
+- **ui:** improve QuotaSelector display of unlimited and singular values ([#1704](https://github.com/sct/overseerr/issues/1704)) ([59b2ec1](https://github.com/sct/overseerr/commit/59b2ec11fa8868bf6873ffa80f4999ae10d65637))
+- perform case-insensitive match for local user email addresses ([#1633](https://github.com/sct/overseerr/issues/1633)) ([928b8a7](https://github.com/sct/overseerr/commit/928b8a71cf361b7bc2b8957c621f5b66c4657b1e))
+- **ui:** apply pointer cursor style for clickable status badges ([#1632](https://github.com/sct/overseerr/issues/1632)) ([6968caa](https://github.com/sct/overseerr/commit/6968caa35a70c172bdd57c984fde6cb6a04a1470))
+- **ui:** remove delete button from request cards ([#1635](https://github.com/sct/overseerr/issues/1635)) ([6b37242](https://github.com/sct/overseerr/commit/6b37242a3f5a3b332d259f4814d235d751ae2491))
+- switch PGP regex to span multiple lines ([#1598](https://github.com/sct/overseerr/issues/1598)) ([d0703aa](https://github.com/sct/overseerr/commit/d0703aa37772759e8e28b5da7187e97e7aadc495))
+- **ui:** hide Plex alert after setup and add local login warning to local user modal ([#1600](https://github.com/sct/overseerr/issues/1600)) ([694d0ff](https://github.com/sct/overseerr/commit/694d0ffcf6b3e3fa00175400fa4217a7d6eb787f))
+
+### Features
+
+- **lang:** add Greek display language ([#1605](https://github.com/sct/overseerr/issues/1605)) ([2241564](https://github.com/sct/overseerr/commit/22415642e8602809e3507e5b13dc2f8de3000003))
+- **lang:** translations update from Weblate ([#1585](https://github.com/sct/overseerr/issues/1585)) ([361ea77](https://github.com/sct/overseerr/commit/361ea77588db3dc04a51dd3a62c73ae1297cdce2))
+- **lang:** translations update from Weblate ([#1603](https://github.com/sct/overseerr/issues/1603)) ([2efa7fa](https://github.com/sct/overseerr/commit/2efa7faf20d05a5fc423e0151c6b46fe6212d096))
+- **lang:** translations update from Weblate ([#1639](https://github.com/sct/overseerr/issues/1639)) ([d22400d](https://github.com/sct/overseerr/commit/d22400dbc9320743498eeb8e6a4dcbccf1a4d52d))
+- **lang:** translations update from Weblate ([#1676](https://github.com/sct/overseerr/issues/1676)) ([8a80571](https://github.com/sct/overseerr/commit/8a805716e3e34ae8d081ad47f9d4cd68f88b0116))
+- **lang:** translations update from Weblate ([#1703](https://github.com/sct/overseerr/issues/1703)) ([6a3649f](https://github.com/sct/overseerr/commit/6a3649f620e518ff07a48c17ce1182aaedff398a))
+- **lang:** translations update from Weblate ([#1727](https://github.com/sct/overseerr/issues/1727)) ([60c3ced](https://github.com/sct/overseerr/commit/60c3ced9e2466568eecde93c88410c87ff0b796f))
+- **lang:** translations update from Weblate ([#1746](https://github.com/sct/overseerr/issues/1746)) ([37a4df6](https://github.com/sct/overseerr/commit/37a4df646cc3e3101360037f1b6f061a734eb5e2))
+- **lang:** translations update from Weblate ([#1768](https://github.com/sct/overseerr/issues/1768)) ([dedf95e](https://github.com/sct/overseerr/commit/dedf95e574a15a708866c381353e58ce3b3a1a61))
+- add display name to create local user modal ([#1631](https://github.com/sct/overseerr/issues/1631)) ([44c3edb](https://github.com/sct/overseerr/commit/44c3edb98568ba15eb525e665115429cfb15d28b))
+- allow users to select notification types ([#1512](https://github.com/sct/overseerr/issues/1512)) ([e605989](https://github.com/sct/overseerr/commit/e60598905b2d6eef7c1872d0c9e92e6d70508ae8))
+- **notif:** prevent manage-request users receiving auto-approve notif from their requests ([#1707](https://github.com/sct/overseerr/issues/1707)) ([#1709](https://github.com/sct/overseerr/issues/1709)) ([9ead8bb](https://github.com/sct/overseerr/commit/9ead8bb1f1680b522550f963502c83e2f99d1e96))
+- **plex:** add support for custom Plex Web App URLs ([#1581](https://github.com/sct/overseerr/issues/1581)) ([a640a91](https://github.com/sct/overseerr/commit/a640a91390f1411637ad379a8253002fdf60480f))
+- **pwa:** add notification badge icon ([#1695](https://github.com/sct/overseerr/issues/1695)) ([9b3b6a9](https://github.com/sct/overseerr/commit/9b3b6a9170b25209e54c74aa9e96659bc2d19edd))
+- **ui:** request list item & request card improvements ([#1532](https://github.com/sct/overseerr/issues/1532)) ([d7b9b1a](https://github.com/sct/overseerr/commit/d7b9b1a525ec6d1d81ad6fe4e55994dd8428988f))
+- **webpush:** add warning to web push settings re: HTTPS requirement ([#1599](https://github.com/sct/overseerr/issues/1599)) ([0c4fb64](https://github.com/sct/overseerr/commit/0c4fb6446be425905a120df5be9a28b052e884c0))
+
+### Reverts
+
+- **deps:** revert back to typeorm 0.2.32 ([4368c3a](https://github.com/sct/overseerr/commit/4368c3aa4f88425ec08f3b555419e572cfa320e3))
+- **deps:** use 10.1.3 until css import issue is resolved ([2254248](https://github.com/sct/overseerr/commit/2254248abc0f2051a9dd28d9663c7ab1d0b547b6))
+- **requests:** go back to old modifiedBy request values for now ([0918b25](https://github.com/sct/overseerr/commit/0918b254132b0541999486e1f0679d0c0cd65864))
+
+# [1.24.0](https://github.com/sct/overseerr/compare/v1.23.2...v1.24.0) (2021-05-05)
+
+### Bug Fixes
+
+- **api:** do not try to transform empty values passed to user notificationTypes ([ef3f977](https://github.com/sct/overseerr/commit/ef3f9778aa81f8ed39dcd835d63d94f2248e0204)), closes [#1501](https://github.com/sct/overseerr/issues/1501)
+- **backend:** properly set request media status ([#1541](https://github.com/sct/overseerr/issues/1541)) ([b7b55e2](https://github.com/sct/overseerr/commit/b7b55e275cb2f1f61c3057cb8ab4cb1027f6356d))
+- **css:** don't target button globally ([#1510](https://github.com/sct/overseerr/issues/1510)) ([f78b9c1](https://github.com/sct/overseerr/commit/f78b9c1ca9648eb10b010e526d9b9db09648b154))
+- **css:** fix cog icon size on media detail pages ([#1520](https://github.com/sct/overseerr/issues/1520)) ([26ddc03](https://github.com/sct/overseerr/commit/26ddc03b2c01b343c24f1c359b78c587310cc747))
+- **email:** parse sender hostname from application URL ([#1518](https://github.com/sct/overseerr/issues/1518)) ([3baa55c](https://github.com/sct/overseerr/commit/3baa55c690dd9ba39768b8b271595cb6b09fe6da))
+- **lang:** correct overwritten email toast strings ([11a5e8d](https://github.com/sct/overseerr/commit/11a5e8d95bc2a2f16adf1e48d2ef38b508a6ace5))
+- **locale:** default user locale should be the server setting ([#1574](https://github.com/sct/overseerr/issues/1574)) ([549103f](https://github.com/sct/overseerr/commit/549103f6f6d5624201e425df7d7814f0f67863b9))
+- **pwa:** add Discover shortcut and fix/optimize icons ([#1525](https://github.com/sct/overseerr/issues/1525)) ([e1dc62b](https://github.com/sct/overseerr/commit/e1dc62b0a5b64202701aff821837ed11dd3f12db))
+- **radarr:** only process Radarr movies which are either monitored or downloaded ([#1511](https://github.com/sct/overseerr/issues/1511)) ([85899ab](https://github.com/sct/overseerr/commit/85899ab49a27542390e91443531905737224338d))
+- **ui:** add missing margins on button SVGs on Plex Settings page ([#1546](https://github.com/sct/overseerr/issues/1546)) ([5e588be](https://github.com/sct/overseerr/commit/5e588be8127b50dd83477f7f3a65f18de774e8af))
+- **ui:** add user profile links to RequestBlock and change 'ETA' string in DownloadBlock ([#1551](https://github.com/sct/overseerr/issues/1551)) ([e4d0029](https://github.com/sct/overseerr/commit/e4d0029f7b4245b8606e2447c54629def40c7761))
+- **ui:** apply rounded-l-only to SensitiveInput textareas and increase visible text input area ([#1561](https://github.com/sct/overseerr/issues/1561)) ([1123fce](https://github.com/sct/overseerr/commit/1123fce089b86251dcafebf77743d60a6e396bee))
+- **ui:** correct RegionSelector z-index ([#1567](https://github.com/sct/overseerr/issues/1567)) ([e912a00](https://github.com/sct/overseerr/commit/e912a00880f856fa9621e8587ef1cc6513a3d49c))
+- **ui:** correct toasts being in the wrong position on smaller screens ([2ecd9d7](https://github.com/sct/overseerr/commit/2ecd9d7b1391b8fc83e9c12a18bab105e7148f0f))
+- **ui:** default to text input type for SensitiveInputs ([#1568](https://github.com/sct/overseerr/issues/1568)) ([e2acf88](https://github.com/sct/overseerr/commit/e2acf8887cb0456c80308bd1b7f3bbe1930e8cff))
+- **ui:** explicitly specify width/height of Listbox dropdown icon ([#1514](https://github.com/sct/overseerr/issues/1514)) ([802e40a](https://github.com/sct/overseerr/commit/802e40a5dfa00f897f9d5a741718a319f74ff030))
+- **ui:** improve form usability ([#1563](https://github.com/sct/overseerr/issues/1563)) ([26580ea](https://github.com/sct/overseerr/commit/26580eaa218702bc5841718310e340d049c50332))
+- **ui:** show warning if user has both a default non-4K server and a non-default 4K server ([#1478](https://github.com/sct/overseerr/issues/1478)) ([4faddf3](https://github.com/sct/overseerr/commit/4faddf3810e20851c7ae1251ff0187fa13d7b0f6))
+- **webpush:** only prompt user to allow notifications if enabled in user settings ([#1552](https://github.com/sct/overseerr/issues/1552)) ([b05b177](https://github.com/sct/overseerr/commit/b05b177776a5d22bf3b5e93bad4358f4007b879a))
+- correctly fall back to English name in LanguageSelector ([#1537](https://github.com/sct/overseerr/issues/1537)) ([189313e](https://github.com/sct/overseerr/commit/189313e94a16e694d192d157642d77f664fd709b))
+- do not set locale when modifying other users ([#1499](https://github.com/sct/overseerr/issues/1499)) ([4858771](https://github.com/sct/overseerr/commit/48587719e9474139c7bbc2970b1c7d1d17b78a81))
+
+### Features
+
+- **email:** replace 'Enable SSL' setting with more descriptive/clear 'Encryption Method' setting ([#1549](https://github.com/sct/overseerr/issues/1549)) ([69ab7cc](https://github.com/sct/overseerr/commit/69ab7cc660bea43b70bdb646eabd3866c1b5a90f))
+- **inputs:** add support for toggling security on input fields ([#1404](https://github.com/sct/overseerr/issues/1404)) ([4fd452d](https://github.com/sct/overseerr/commit/4fd452dd1880f597a0acda812d567e7cb6c16d83))
+- **lang:** translated using Weblate (Spanish) ([#1553](https://github.com/sct/overseerr/issues/1553)) ([e3d5e33](https://github.com/sct/overseerr/commit/e3d5e33ec3e43d36ec832d6ca47f330fc7675088))
+- **lang:** translations update from Weblate ([#1497](https://github.com/sct/overseerr/issues/1497)) ([9a95a07](https://github.com/sct/overseerr/commit/9a95a073916c9968b8ef348d0805d77400ea203a))
+- **lang:** translations update from Weblate ([#1527](https://github.com/sct/overseerr/issues/1527)) ([1a6d4bd](https://github.com/sct/overseerr/commit/1a6d4bddc016f4aaad83b945e103b19be4d0da31))
+- **lang:** translations update from Weblate ([#1558](https://github.com/sct/overseerr/issues/1558)) ([6c9991d](https://github.com/sct/overseerr/commit/6c9991d474a5cd95d9a0a10104bd79d8a9f3ada9))
+- **lang:** translations update from Weblate ([#1566](https://github.com/sct/overseerr/issues/1566)) ([93c441e](https://github.com/sct/overseerr/commit/93c441ef6665291ca3698368e4b093c843726036))
+- add server default locale setting ([#1536](https://github.com/sct/overseerr/issues/1536)) ([f256a44](https://github.com/sct/overseerr/commit/f256a444c57f2d92c1c4918d4ff6e223ef85ecd2))
+- **notif:** add LunaSea agent ([#1495](https://github.com/sct/overseerr/issues/1495)) ([4e6fb00](https://github.com/sct/overseerr/commit/4e6fb00a4a59545817add1544c0b1555078809a4))
+- **notif:** show success/failure toast for test notifications ([#1442](https://github.com/sct/overseerr/issues/1442)) ([079645c](https://github.com/sct/overseerr/commit/079645c2c74edfb7e4f583de2ac72bb9824f6524))
+- **perms:** add separate REQUEST_MOVIE and REQUEST_TV permissions ([#1474](https://github.com/sct/overseerr/issues/1474)) ([91b9e0f](https://github.com/sct/overseerr/commit/91b9e0f67996a442b5c0117fe09e2d69c163fafb))
+- **pwa:** add shortcuts to PWA ([#1509](https://github.com/sct/overseerr/issues/1509)) ([ed99e49](https://github.com/sct/overseerr/commit/ed99e4976dc2700fe84c70af4887c1a431bba92c))
+- add option to only allow Plex sign-in from existing users ([#1496](https://github.com/sct/overseerr/issues/1496)) ([db49b20](https://github.com/sct/overseerr/commit/db49b2024d399d90f2d1500b262374efc42f333c))
+- PWA Support ([#1488](https://github.com/sct/overseerr/issues/1488)) ([28830d4](https://github.com/sct/overseerr/commit/28830d4ef809efa92a5879a81cac11ff52ea3d1f))
+
+## [1.23.2](https://github.com/sct/overseerr/compare/v1.23.1...v1.23.2) (2021-04-21)
+
+### Bug Fixes
+
+- **lang:** add missing '4K' from singular case of approve/deny 4K request strings ([#1481](https://github.com/sct/overseerr/issues/1481)) ([a822b01](https://github.com/sct/overseerr/commit/a822b019220e86e362a2570e7024289450b4ed46))
+- **ui:** change 'Disable Auto-Search' checkbox to 'Enable Automatic Search' ([#1476](https://github.com/sct/overseerr/issues/1476)) ([1a311d2](https://github.com/sct/overseerr/commit/1a311d211d78731c9089e66ed5387c1b5afe33c0))
+- better error message when creating a user with an existing email ([f13f1c9](https://github.com/sct/overseerr/commit/f13f1c94515b5bd51382fa18ad96a2ccfd06e50d)), closes [#1441](https://github.com/sct/overseerr/issues/1441)
+- set editRequest attribute as necessary, allow users to edit their own pending requests, and show 'View Request' button on series pages ([#1446](https://github.com/sct/overseerr/issues/1446)) ([89455ad](https://github.com/sct/overseerr/commit/89455ad9b783d04d993a0009c351b1096f2b222e))
+- **api:** add check for 4K request perms to request creation endpoint ([#1450](https://github.com/sct/overseerr/issues/1450)) ([4449241](https://github.com/sct/overseerr/commit/4449241a8f63fdaeaa4995aa7ec34127c322b9dd))
+- **notif:** include year in notifications ([#1439](https://github.com/sct/overseerr/issues/1439)) ([4e98f56](https://github.com/sct/overseerr/commit/4e98f567534a650e26b0244990b7ca549cecbe89))
+- **plex:** add support for plex.direct URLs ([#1437](https://github.com/sct/overseerr/issues/1437)) ([db07770](https://github.com/sct/overseerr/commit/db077700e42ab1d2c870213fd55bbdee74002775))
+- **radarr:** search in addition to monitoring existing movies ([#1449](https://github.com/sct/overseerr/issues/1449)) ([3ae7d00](https://github.com/sct/overseerr/commit/3ae7d0098b225562499d7c8a74b8b6c3e8893ad9))
+- **ui:** adjust user list buttons on mobile ([#1452](https://github.com/sct/overseerr/issues/1452)) ([5d1b741](https://github.com/sct/overseerr/commit/5d1b741f55665c528e299a09464dff6d66f72666))
+- **ui:** align icons in user dropdown ([eb5d152](https://github.com/sct/overseerr/commit/eb5d1528869959cdf642e6fefc1a8f4dcf51b84e))
+
+## [1.23.1](https://github.com/sct/overseerr/compare/v1.23.0...v1.23.1) (2021-04-16)
+
+### Bug Fixes
+
+- **api:** correctly check if update is available for release versions ([190cbd6](https://github.com/sct/overseerr/commit/190cbd6559c51a02ec09b267891f3033add6afc8))
+
+# [1.23.0](https://github.com/sct/overseerr/compare/v1.22.0...v1.23.0) (2021-04-16)
+
+### Bug Fixes
+
+- **api:** allow server owner to delete other admin accounts ([2ac6fe7](https://github.com/sct/overseerr/commit/2ac6fe7f6d666d64228d11cde24865acc54c7ce7))
+- **backend:** do not log error when user has no server access ([#1419](https://github.com/sct/overseerr/issues/1419)) ([fc14037](https://github.com/sct/overseerr/commit/fc14037ec1c0b7450d892fa9be8176f5b9ff9d73))
+- **frontend:** add crossorigin attribute to webmanifest link ([#1376](https://github.com/sct/overseerr/issues/1376)) ([82ca2f5](https://github.com/sct/overseerr/commit/82ca2f59349407e3b1b5cd4f321e196f37044df0))
+- **frontend:** autofill with Plex server address ([#1381](https://github.com/sct/overseerr/issues/1381)) ([d9e314b](https://github.com/sct/overseerr/commit/d9e314bad295463d26d8ffe92728f3b5eee4ad05))
+- **frontend:** handle media items/requests no longer having a valid tmdb id ([b5ac2f5](https://github.com/sct/overseerr/commit/b5ac2f5a2c5dda808eca177359f125d6e03d1b0f)), closes [#517](https://github.com/sct/overseerr/issues/517)
+- **lang:** remove unused strings & correct manageModalNoRequests strings ([#1413](https://github.com/sct/overseerr/issues/1413)) ([190a5c0](https://github.com/sct/overseerr/commit/190a5c0723d4aeafc4ad6103d52c2042a4eaed0e))
+- **plex:** do not use SSL for local servers ([#1418](https://github.com/sct/overseerr/issues/1418)) ([9233fc0](https://github.com/sct/overseerr/commit/9233fc078579df8a193344ba45bafb0d5c2cb9af))
+- **plex:** use server 'address' returned by Plex API ([#1379](https://github.com/sct/overseerr/issues/1379)) ([33542c9](https://github.com/sct/overseerr/commit/33542c9b2dc53b1e036a7d9571cf467c3d3dc8af))
+- **quotas:** Time value of a quota was being ignored ([d3c6bc1](https://github.com/sct/overseerr/commit/d3c6bc1619c39b1e6225d405efaad5df99a27406))
+- **ui:** allow canceling from request list & hide edit button for own requests ([#1401](https://github.com/sct/overseerr/issues/1401)) ([bed850d](https://github.com/sct/overseerr/commit/bed850dce9ad0d0b52c3c628225aea938164c38b))
+- **ui:** close sidebar on mobile when clicking version status ([ad67381](https://github.com/sct/overseerr/commit/ad673813976669797202c2cefc50274aca84989d))
+- **ui:** correctly set autocomplete attribute for password fields ([#1430](https://github.com/sct/overseerr/issues/1430)) ([4b5e355](https://github.com/sct/overseerr/commit/4b5e355df9e291a5cb550483c7dad6c43f03d3a7))
+- **ui:** dim password field when password generation option is selected ([#1427](https://github.com/sct/overseerr/issues/1427)) ([e8bbd44](https://github.com/sct/overseerr/commit/e8bbd4497a5eab6357fa7b37c9906285b3d1f64f))
+- **ui:** hide alert when email notifs are already configured ([#1335](https://github.com/sct/overseerr/issues/1335)) ([5117987](https://github.com/sct/overseerr/commit/5117987feaed21ccc19e64b04a15f2b77c22b880))
+- fall back to English genre names ([#1352](https://github.com/sct/overseerr/issues/1352)) ([e43106a](https://github.com/sct/overseerr/commit/e43106a434548840acecaf1276a5cebdc30e1345))
+- fix outofdate string & display version status badge in Settings > About ([#1417](https://github.com/sct/overseerr/issues/1417)) ([4eb9209](https://github.com/sct/overseerr/commit/4eb92098ba1f141bf74875ce76816a615763de5f))
+- various fixes for new tags feature ([#1369](https://github.com/sct/overseerr/issues/1369)) ([b4450a3](https://github.com/sct/overseerr/commit/b4450a308c56f767fbaa769d574a1b3f8e221d59))
+- **ui:** link request card status badge to Plex media URL ([#1361](https://github.com/sct/overseerr/issues/1361)) ([7a5c4a3](https://github.com/sct/overseerr/commit/7a5c4a30b5735fe6fbe821a8fcfdb4bcbeca68b3))
+
+### Features
+
+- **lang:** Translations update from Weblate ([#1429](https://github.com/sct/overseerr/issues/1429)) ([a54241c](https://github.com/sct/overseerr/commit/a54241c775705fadc7c044f5312307f28f9a854b))
+- change alpha warning to beta warning ([03fd21b](https://github.com/sct/overseerr/commit/03fd21bebc3ffa34ce983b524d09e74b8ab2d057))
+- **lang:** translated using Weblate (Catalan) ([#1351](https://github.com/sct/overseerr/issues/1351)) ([35c13a8](https://github.com/sct/overseerr/commit/35c13a87467b4deabab3cb2cd1cab1b24ab51875))
+- **lang:** translations update from Weblate ([#1360](https://github.com/sct/overseerr/issues/1360)) ([8ee7693](https://github.com/sct/overseerr/commit/8ee7693a1f00a2f735b2555c7f8180c8a2c6144f))
+- **lang:** translations update from Weblate ([#1416](https://github.com/sct/overseerr/issues/1416)) ([dceca4d](https://github.com/sct/overseerr/commit/dceca4dd97f78f2e3aef678edcd5755c781f5249))
+- add overseerr version and update availability status to sidebar ([ecf1312](https://github.com/sct/overseerr/commit/ecf13123d21d765d67bfa7f9b6509b0f2af62cee))
+- **lang:** translations update from Weblate ([#1388](https://github.com/sct/overseerr/issues/1388)) ([9b199b2](https://github.com/sct/overseerr/commit/9b199b27d806e290cf0551e2d2ede6add61770aa))
+- **lang:** translations update from Weblate ([#1396](https://github.com/sct/overseerr/issues/1396)) ([3daf57e](https://github.com/sct/overseerr/commit/3daf57e9a12e4973dbc56656379ab2dbcb3c2619))
+- **notif:** allow users to enable/disable specific agents ([#1172](https://github.com/sct/overseerr/issues/1172)) ([46c4ee1](https://github.com/sct/overseerr/commit/46c4ee1625cf3e74bd885ecfc254b1e46cf44f29))
+- **webhook:** include requestedBy user in payload ([#1385](https://github.com/sct/overseerr/issues/1385)) ([e605687](https://github.com/sct/overseerr/commit/e60568758097d07f9d4b201ffdf34f0c32ba9cf3))
+- radarr/sonarr tag support ([#1366](https://github.com/sct/overseerr/issues/1366)) ([a306ebc](https://github.com/sct/overseerr/commit/a306ebc2d18317d8dbe4ccd3f24c22f55ffcd6a6))
+
+# [1.22.0](https://github.com/sct/overseerr/compare/v1.21.1...v1.22.0) (2021-04-01)
+
+### Bug Fixes
+
+- **android:** adaptive icons for Android devices ([#1274](https://github.com/sct/overseerr/issues/1274)) ([a65e3d5](https://github.com/sct/overseerr/commit/a65e3d5bb6924cbde30b26ff8acf535e5274efee))
+- **backend:** fix getShowByTvdbId() error message ([#1314](https://github.com/sct/overseerr/issues/1314)) [skip ci] ([fe8d346](https://github.com/sct/overseerr/commit/fe8d34607b07095dce51b29ef7aaae0485573f14))
+- **db:** enable WAL journal mode ([aa205ff](https://github.com/sct/overseerr/commit/aa205ffa975d02ef0be30626e7c946a42679a847))
+- **frontend:** 'Recent Requests' slider should link to request list w/ same filter ([#1235](https://github.com/sct/overseerr/issues/1235)) ([49782c0](https://github.com/sct/overseerr/commit/49782c0b730cce9f0bad14e9c83842b5b0bfe11e))
+- **frontend:** call mutate after changing public settings ([#1302](https://github.com/sct/overseerr/issues/1302)) ([c8f67cf](https://github.com/sct/overseerr/commit/c8f67cf866ada791e4129a0bbae16b9eac41f32e))
+- **frontend:** include language parameter in TMDb links ([#1344](https://github.com/sct/overseerr/issues/1344)) ([1d88be9](https://github.com/sct/overseerr/commit/1d88be9341a8ff9e1f39b02556b489cdbd06392b))
+- **frontend:** redirect from /setup if already initialized ([#1238](https://github.com/sct/overseerr/issues/1238)) ([8016503](https://github.com/sct/overseerr/commit/80165038fd214897e3520a420f971341e7b94865))
+- **frontend:** use correct path to user profile in request modal quota dropdown ([#1307](https://github.com/sct/overseerr/issues/1307)) ([f990585](https://github.com/sct/overseerr/commit/f9905859148088afec53549b81611b07bf19d3b9))
+- **frontend:** use HTTPS to fetch TMDb assets for network/studio sliders ([#1343](https://github.com/sct/overseerr/issues/1343)) ([c886ea6](https://github.com/sct/overseerr/commit/c886ea6c0578cb7532d6c09266a76bfad8598b9d))
+- **frontend:** use next/image to serve login page images ([cbf4519](https://github.com/sct/overseerr/commit/cbf45196b023f60c8e4cf7602c0295f886fe610c)), closes [#1207](https://github.com/sct/overseerr/issues/1207)
+- **lang:** allow proper localization of comma-delimited lists ([#1264](https://github.com/sct/overseerr/issues/1264)) ([173408a](https://github.com/sct/overseerr/commit/173408a1f269f09c724843ba087ef3f85b2832ad))
+- **lang:** change 'Extra Data' string to 'Additional Data' ([#1226](https://github.com/sct/overseerr/issues/1226)) ([665e164](https://github.com/sct/overseerr/commit/665e16475f3fa2ea6118340d9ea2d30b98abb238))
+- **lang:** correct mismatched language strings ([#1246](https://github.com/sct/overseerr/issues/1246)) ([8ebc829](https://github.com/sct/overseerr/commit/8ebc8292504cdc57a148ab69bcb4e1514ef018c6))
+- **lang:** correct strings for library sync button & user import toast ([#1252](https://github.com/sct/overseerr/issues/1252)) ([cb5ca7a](https://github.com/sct/overseerr/commit/cb5ca7acf38dcc2e27ec31d88434a11757cdb469))
+- **lang:** edit setting label strings for verb tense consistency ([#1214](https://github.com/sct/overseerr/issues/1214)) ([6d7671d](https://github.com/sct/overseerr/commit/6d7671dd80fea632e5cef29fc0b4968bffe231b0))
+- **lang:** fix overwritten/shared string ([#1212](https://github.com/sct/overseerr/issues/1212)) ([dfd4ff9](https://github.com/sct/overseerr/commit/dfd4ff9229822b0ce79ba322376194cbb6fd233d))
+- **lang:** remove 'requires and' ([#1215](https://github.com/sct/overseerr/issues/1215)) ([cb852fd](https://github.com/sct/overseerr/commit/cb852fded18f53806c23ec6f215385072b2a867b))
+- **lang:** remove unused strings ([#1330](https://github.com/sct/overseerr/issues/1330)) ([13e1595](https://github.com/sct/overseerr/commit/13e1595c6ebff32ca905d9bd3dd781e241545e83))
+- **lang:** UI string edits, round 2 ([#1202](https://github.com/sct/overseerr/issues/1202)) ([ea1863a](https://github.com/sct/overseerr/commit/ea1863ac3a5d3051e07815d07df0d3f2abd9166f))
+- **log:** fix typo in base scanner logging ([#1329](https://github.com/sct/overseerr/issues/1329)) [skip ci] ([b0b04ca](https://github.com/sct/overseerr/commit/b0b04ca1c7218ad5b67d9ec8b3fac5af78a4c132))
+- **logs:** add i18n strings for new log page changes ([8c51c28](https://github.com/sct/overseerr/commit/8c51c28f546b9c2d38ff7f20d59bb08a599e8146))
+- **notifications:** correctly send notifications for users that do not have any user settings yet ([d3a25b9](https://github.com/sct/overseerr/commit/d3a25b935aae35dd97ef0f168ac7e2898126a9a5)), closes [#1324](https://github.com/sct/overseerr/issues/1324)
+- **overseerr-api.yml:** fixed pushbullet & webhook API definition refs and descriptions ([#1288](https://github.com/sct/overseerr/issues/1288)) [skip ci] ([3b003b7](https://github.com/sct/overseerr/commit/3b003b770120f7d150c64ff098b626015c030794))
+- **plex:** always send Overseerr for the device name to the plex.tv api ([f7146e4](https://github.com/sct/overseerr/commit/f7146e41899a59f75b963e1cc9dac9eddf24aebe)), closes [#1244](https://github.com/sct/overseerr/issues/1244)
+- **ui:** add validation to hostname/IP fields ([#1206](https://github.com/sct/overseerr/issues/1206)) ([f49a024](https://github.com/sct/overseerr/commit/f49a02449c4928aef56cecbf908cf585ea0d4fca))
+- **ui:** better regex matching when parsing logs ([#1225](https://github.com/sct/overseerr/issues/1225)) ([2d737f2](https://github.com/sct/overseerr/commit/2d737f276095a8ca9abea360ef29134e9f639a39))
+- **ui:** button w/ dropdown z-indices ([#1230](https://github.com/sct/overseerr/issues/1230)) ([015671f](https://github.com/sct/overseerr/commit/015671f5be7a9f0f5c38db5a11a4b3c788dfaade))
+- **ui:** center role under title cards on person detail pages ([#1205](https://github.com/sct/overseerr/issues/1205)) ([4a61518](https://github.com/sct/overseerr/commit/4a6151873a3a3c5e45f9817131774a2c52957138))
+- **ui:** correctly enable the request button when partial requests are disabled with no quota ([16a611b](https://github.com/sct/overseerr/commit/16a611b9dfc3c66483640f4f5364646f41d37159))
+- **ui:** correctly paginate request list ([67fbb40](https://github.com/sct/overseerr/commit/67fbb401ac6ba05e58b8dfefd5954b28316254f2))
+- **ui:** correctly show quota display on tv request modal when only series quota is set ([3f1f85a](https://github.com/sct/overseerr/commit/3f1f85a80edfd2a4e9627162ff29ca6bcf2d8583))
+- **ui:** display asterisk indicator on required field labels ([#1236](https://github.com/sct/overseerr/issues/1236)) ([380d361](https://github.com/sct/overseerr/commit/380d36119f19a20ad67f79b3fb5db4036a093cac))
+- **ui:** do not check isValid on Sonarr/Radarr modals for the test button ([0974a4c](https://github.com/sct/overseerr/commit/0974a4c971358b7a64668f9a63fc356234a656c9))
+- **ui:** do not require numeric value in FormattedRelativeTime ([#1234](https://github.com/sct/overseerr/issues/1234)) ([3642b1e](https://github.com/sct/overseerr/commit/3642b1e84a20fef72428b3e240c86d35be8be8a2))
+- **ui:** filter out server options that do not match request type (non-4K or 4K) ([#1183](https://github.com/sct/overseerr/issues/1183)) ([28a6a70](https://github.com/sct/overseerr/commit/28a6a70e1ecc125f4cf4900e599ad0d4d7b55e3b))
+- **ui:** fix label formatting in general user settings ([#1275](https://github.com/sct/overseerr/issues/1275)) ([8546b0e](https://github.com/sct/overseerr/commit/8546b0ef53d232256b62cf08466e692a6971c16b))
+- **ui:** fix regex matching when parsing label from logs ([#1231](https://github.com/sct/overseerr/issues/1231)) ([4a00617](https://github.com/sct/overseerr/commit/4a00617fe47064ea50f95a02f29832a419ab13a3))
+- **ui:** gracefully handle lengthy titles & long words in overviews ([#1338](https://github.com/sct/overseerr/issues/1338)) ([d8bcb99](https://github.com/sct/overseerr/commit/d8bcb99b2fd3b24a5119ba5ff213a640425ff553))
+- **ui:** hide 'show details' button if there are no additional details ([#1254](https://github.com/sct/overseerr/issues/1254)) ([6210f12](https://github.com/sct/overseerr/commit/6210f12e8e9f593d629d22278d78310482ca0cfa))
+- **ui:** increase page size dropdown width when necessary ([#1216](https://github.com/sct/overseerr/issues/1216)) ([75c72b9](https://github.com/sct/overseerr/commit/75c72b987eb52b907ffd8af33f15ecc58213fc12))
+- **ui:** restore saved states of quota override checkboxes ([#1282](https://github.com/sct/overseerr/issues/1282)) ([2059fc1](https://github.com/sct/overseerr/commit/2059fc1cd4d48c7d80e761b7d41b7ec122d82769))
+- **ui:** sort regions & languages by their localized names rather than their TMDb English names ([#1157](https://github.com/sct/overseerr/issues/1157)) ([d76bf32](https://github.com/sct/overseerr/commit/d76bf32c9dcc83ebd0bae979726b1456a9028d8b))
+- **ui:** tweak request list design ([#1201](https://github.com/sct/overseerr/issues/1201)) ([d226fc7](https://github.com/sct/overseerr/commit/d226fc79b8d5f1263d4b80a7a1772074020ec94f))
+- **ui:** use appropriate cursor type for disabled UI elements ([#1184](https://github.com/sct/overseerr/issues/1184)) ([b767a58](https://github.com/sct/overseerr/commit/b767a58b011cc317a889cb8c2889b3210bec5fae))
+- **ui:** use appropriate cursor type for readonly input fields ([#1208](https://github.com/sct/overseerr/issues/1208)) ([9ec2c46](https://github.com/sct/overseerr/commit/9ec2c468cbbcbd41b94bbf9f3cfeb43eed09f36e))
+- **ui:** use correct colspan for 'No results.' message in Settings > Logs ([#1325](https://github.com/sct/overseerr/issues/1325)) ([5c135c9](https://github.com/sct/overseerr/commit/5c135c9974ebfcbdb434dafd459d1035624df6ed))
+- fetch localized person details from TMDb ([#1243](https://github.com/sct/overseerr/issues/1243)) ([1d7a938](https://github.com/sct/overseerr/commit/1d7a938ef8b0b8c20fda5024121de2a217ef4127))
+
+### Features
+
+- **frontend:** add apple splash for pwa ([232def9](https://github.com/sct/overseerr/commit/232def972b9156afcbd83592708dbf8b5866ee24))
+- **frontend:** add apple tv+ to network slider ([3dc27ff](https://github.com/sct/overseerr/commit/3dc27ffd9bb054e6cda58872939dbc352877d184)), closes [#1219](https://github.com/sct/overseerr/issues/1219)
+- **frontend:** allow selecting multiple original languages ([a908c07](https://github.com/sct/overseerr/commit/a908c07670532b0ca7f766065bb4653ce2376e6f))
+- **lang:** add Catalan to language picker ([#1309](https://github.com/sct/overseerr/issues/1309)) ([77911c0](https://github.com/sct/overseerr/commit/77911c03e98aa3c2c6c062a01c22b030704309c2))
+- **lang:** translations update from Weblate ([#1178](https://github.com/sct/overseerr/issues/1178)) ([3c89010](https://github.com/sct/overseerr/commit/3c89010629bc16f225f1d3936abe9f4e47a0d7c7))
+- **lang:** translations update from Weblate ([#1224](https://github.com/sct/overseerr/issues/1224)) ([c1975b3](https://github.com/sct/overseerr/commit/c1975b33f1115a95068be000b7f479a401f0f0ae))
+- **lang:** translations update from Weblate ([#1237](https://github.com/sct/overseerr/issues/1237)) ([dabd32a](https://github.com/sct/overseerr/commit/dabd32a18b42980059c7a7a7450514ca827a5d3b))
+- **lang:** translations update from Weblate ([#1256](https://github.com/sct/overseerr/issues/1256)) ([e9b1a9e](https://github.com/sct/overseerr/commit/e9b1a9e80e6b8285fa451a8551c5832a850c1746))
+- **lang:** translations update from Weblate ([#1281](https://github.com/sct/overseerr/issues/1281)) ([bec1d3d](https://github.com/sct/overseerr/commit/bec1d3dde834b9a50e24c5894c362e5982ff3bd5))
+- **lang:** translations update from Weblate ([#1305](https://github.com/sct/overseerr/issues/1305)) ([1b129c0](https://github.com/sct/overseerr/commit/1b129c0b3863ea3c5ad34c66b3ace5d09cd4e391))
+- **lang:** translations update from Weblate ([#1313](https://github.com/sct/overseerr/issues/1313)) ([18ce349](https://github.com/sct/overseerr/commit/18ce349faac6ee560b9c92374039954f2365a8d1))
+- **logs:** add copy to clipboard button to logs page ([e2b8745](https://github.com/sct/overseerr/commit/e2b8745fdc192f3d49872625652184005a760885))
+- **notif:** include requested season numbers in notifications ([#1211](https://github.com/sct/overseerr/issues/1211)) ([4ee78ab](https://github.com/sct/overseerr/commit/4ee78ab2fe0359df6baa58f0986687f05a8392a2))
+- **requests:** add request quotas ([#1277](https://github.com/sct/overseerr/issues/1277)) ([6c75c88](https://github.com/sct/overseerr/commit/6c75c8822842514ffd31864992e8d3ce686fea1b))
+- **settings:** logs viewer ([#997](https://github.com/sct/overseerr/issues/997)) ([54429bb](https://github.com/sct/overseerr/commit/54429bbc1d765d0e50486a42749f9bbd4e5b3386))
+- **ui:** add movie/series genre list pages ([#1194](https://github.com/sct/overseerr/issues/1194)) ([6f1a31d](https://github.com/sct/overseerr/commit/6f1a31de473d1a25bc77e0961a52b07050b64c51))
+- **ui:** add option to only allow complete series requests ([#1164](https://github.com/sct/overseerr/issues/1164)) ([36c00fd](https://github.com/sct/overseerr/commit/36c00fde273799a56ec42ce6177ff44fed0904c3))
+- **ui:** Add user requests page ([#936](https://github.com/sct/overseerr/issues/936)) ([a9461f7](https://github.com/sct/overseerr/commit/a9461f760d8112f2ae16183e796f706d3392f8ec))
+- **ui:** allow any value 1-100 for quota limit/days ([#1337](https://github.com/sct/overseerr/issues/1337)) ([f4bed9a](https://github.com/sct/overseerr/commit/f4bed9a63b6b856ebedca9eb7662cd00038d7f7c))
+- **ui:** display movie/series original title ([#1240](https://github.com/sct/overseerr/issues/1240)) ([7230915](https://github.com/sct/overseerr/commit/723091509414465e98d870b3dc943f41b9ac590d))
+- **ui:** experimental status bar style change for ios pwa app ([958cdf9](https://github.com/sct/overseerr/commit/958cdf98fd1cb7c1bdb33aebb6c061750e9ab331))
+- **ui:** store sort order and page size of userlist in localstorage ([#1262](https://github.com/sct/overseerr/issues/1262)) ([f5f8269](https://github.com/sct/overseerr/commit/f5f8269cd28ee792120060f4f38ef09d571fb8d5))
+- add option to cache images locally ([#1213](https://github.com/sct/overseerr/issues/1213)) ([0ca3d43](https://github.com/sct/overseerr/commit/0ca3d4374942b54b59a19d017ab4ae14ba7019c1))
+- genre sliders (experiment) ([#1182](https://github.com/sct/overseerr/issues/1182)) ([1c4515a](https://github.com/sct/overseerr/commit/1c4515a1ae6097f3948aaa0d0ed210831581fd98))
+
+### Reverts
+
+- **ui:** remove local image cache option from settings page ([911faef](https://github.com/sct/overseerr/commit/911faeff562b737a2d18a395fcd90bf354af0cc4))
+- remove experimental tailwind jit compiler until title card hover is fixed ([1df67ba](https://github.com/sct/overseerr/commit/1df67baf9e7cdabc4045a0c115735797e8081bca))
+- **deps:** revert react-intl to 5.13.5 ([e16277c](https://github.com/sct/overseerr/commit/e16277c07d58ddbb749f4a60bc05924f4a5af146))
+
+## [1.21.1](https://github.com/sct/overseerr/compare/v1.21.0...v1.21.1) (2021-03-15)
+
+### Bug Fixes
+
+- **lang:** translations update from Weblate ([#1155](https://github.com/sct/overseerr/issues/1155)) ([ebc285c](https://github.com/sct/overseerr/commit/ebc285c758f69846e4a5cb74bb42ca5924d166d4))
+
+# [1.21.0](https://github.com/sct/overseerr/compare/v1.20.1...v1.21.0) (2021-03-15)
+
+### Bug Fixes
+
+- do not allow editing of user settings under certain conditions ([#1168](https://github.com/sct/overseerr/issues/1168)) ([001dcd3](https://github.com/sct/overseerr/commit/001dcd328c8d3b1c417fd7c7ee2aa20183b08eef))
+- **frontend:** check for ID instead of email after initial setup Plex login ([#1097](https://github.com/sct/overseerr/issues/1097)) ([778dda6](https://github.com/sct/overseerr/commit/778dda67d54df87347dd79577ef1bdc88d3c1d3f))
+- **frontend:** check if swr is validating to determine if we should fetch new data ([e5f5bdb](https://github.com/sct/overseerr/commit/e5f5bdb95c62eba31a3321a7457d354f0226bf85)), closes [#719](https://github.com/sct/overseerr/issues/719)
+- **frontend:** never hide available content in search results ([d48edeb](https://github.com/sct/overseerr/commit/d48edeb5a9bd8e2edce8bca0fea50e300bb7a1ae))
+- **lang:** add missing i18n strings ([6072e8a](https://github.com/sct/overseerr/commit/6072e8aa9a0f84e50c44a92af303aad15b5f3021))
+- **lang:** edit new Telegram-related strings to conform to style guide ([#1093](https://github.com/sct/overseerr/issues/1093)) ([bdf67e7](https://github.com/sct/overseerr/commit/bdf67e732b6c77cbae768a25edfc9a663ef0108b))
+- **notif:** loosen input validation on Pushover settings ([#1166](https://github.com/sct/overseerr/issues/1166)) ([3148d31](https://github.com/sct/overseerr/commit/3148d312141248653c5d1e42cd2882a67a339163))
+- **notif:** set URL for Discord embeds rather than adding a field for the link ([#1167](https://github.com/sct/overseerr/issues/1167)) ([0bd0912](https://github.com/sct/overseerr/commit/0bd0912613f0db24bd0da4ec956b5119133e35d4))
+- correctly send auto-approval notifictions for series ([8634081](https://github.com/sct/overseerr/commit/8634081c869a2078793ecf06b1b7e249bba0a2f8))
+- **lang:** fix singular form of season count ([#1080](https://github.com/sct/overseerr/issues/1080)) ([b57645d](https://github.com/sct/overseerr/commit/b57645d382361c856281e7a74295afe16c5390f2))
+- **requests:** add plex url to request item ([#1088](https://github.com/sct/overseerr/issues/1088)) ([420038d](https://github.com/sct/overseerr/commit/420038d5ffdd4070df03e5c5cb6ef8d6208fddb5))
+- **sonarr:** correctly search when updating existing sonarr series ([ed0a7fb](https://github.com/sct/overseerr/commit/ed0a7fbdf5122a26fa936e83b76a97c55781782d)), closes [#588](https://github.com/sct/overseerr/issues/588)
+- **ui:** add alt prop to studio/network logos & fix blinking text cursor ([#1095](https://github.com/sct/overseerr/issues/1095)) ([0c4637f](https://github.com/sct/overseerr/commit/0c4637f779d8904037b9cbd5fe9166cf05a891c5))
+- **ui:** add link to poster image on request items ([7289872](https://github.com/sct/overseerr/commit/7289872937d5bb94d027424760ee1ceb94095604))
+- **ui:** correct language usage re: "sync" vs. "scan" ([#1079](https://github.com/sct/overseerr/issues/1079)) ([e98f2b9](https://github.com/sct/overseerr/commit/e98f2b96058fb9c5af77be2e8a1bd07fb8fcca06))
+- **ui:** display "Season" vs. "Seasons" as appropriate, and fix request block "Seasons" formatting ([#1127](https://github.com/sct/overseerr/issues/1127)) ([45886cc](https://github.com/sct/overseerr/commit/45886ccef1bee57dc555060a491834567e45b59c))
+- **ui:** request list button sizes ([#1152](https://github.com/sct/overseerr/issues/1152)) ([fc73592](https://github.com/sct/overseerr/commit/fc73592b69c38191f91a68a020868b8e5ec2e2e2))
+- fix language filter link on movie detail pages ([#1142](https://github.com/sct/overseerr/issues/1142)) ([60d453b](https://github.com/sct/overseerr/commit/60d453b0bbba5e2060f72f40d1dde85ec6b05af4))
+- remove language/region filtering on studio/network results ([#1129](https://github.com/sct/overseerr/issues/1129)) ([109aca8](https://github.com/sct/overseerr/commit/109aca8229dc7b81cac314d84591f1c04c12ac2e))
+- **api:** check correct permissions for auto approve when requests are created ([3c1a72b](https://github.com/sct/overseerr/commit/3c1a72b038fd178b4be4dc082cd1496474148d7e))
+- **frontend:** status, requested by, and modified alignment fix ([#1109](https://github.com/sct/overseerr/issues/1109)) ([1a7dc1a](https://github.com/sct/overseerr/commit/1a7dc1acf57888d3d0285b58c1c97a824a232216))
+- **ui:** don't show "Password" user settings tab if current user lacks perms to modify the password ([#1063](https://github.com/sct/overseerr/issues/1063)) ([b146d11](https://github.com/sct/overseerr/commit/b146d11e2ffecedae76472b0491a4662ca4a4a4e))
+- **ui:** fix Radarr logo alignment ([#1068](https://github.com/sct/overseerr/issues/1068)) ([0fa005a](https://github.com/sct/overseerr/commit/0fa005a99cd868b5a235ae9ce65b4c64b05d0f47))
+- **ui:** fix request list UI behavior when season list is too long ([#1106](https://github.com/sct/overseerr/issues/1106)) ([8507691](https://github.com/sct/overseerr/commit/85076919c6ccbf052699b7d5f4ba8b6e5e5af74d))
+- **ui:** improve responsive design on new request list UI ([#1105](https://github.com/sct/overseerr/issues/1105)) ([1f8b03f](https://github.com/sct/overseerr/commit/1f8b03ff6f67ce76051667de05166da54ed3dc89))
+- **ui:** list all movie studios instead of just the first result ([#1110](https://github.com/sct/overseerr/issues/1110)) ([239202d](https://github.com/sct/overseerr/commit/239202d9c11f27410b0fa084bcc4c824b7136081))
+- add correct permission checks to modifying user password/permissions ([ddfc5e6](https://github.com/sct/overseerr/commit/ddfc5e6aa8fc636931f495d6f23d56367466e3b5))
+
+### Features
+
+- add tagline, episode runtime, genres list to media details & clean/refactor CSS into globals ([#1160](https://github.com/sct/overseerr/issues/1160)) ([2f2e002](https://github.com/sct/overseerr/commit/2f2e00237d43bdab85bfadc3c4f2fbcdde4c2e90))
+- **docker:** add tini to docker image ([#1017](https://github.com/sct/overseerr/issues/1017)) ([1629d02](https://github.com/sct/overseerr/commit/1629d02f3d8368bfd5f6fed05382974ae6fce51f))
+- **email:** add pgp support ([#1138](https://github.com/sct/overseerr/issues/1138)) ([9e5adeb](https://github.com/sct/overseerr/commit/9e5adeb610bdc4800ff536412d0ae8a11fb4338d))
+- **frontend:** add loading bar indicator ([#1170](https://github.com/sct/overseerr/issues/1170)) ([3d6b343](https://github.com/sct/overseerr/commit/3d6b3434138fec49c58f2bf74f781d5e2fc2911f))
+- **lang:** localize job names ([#1043](https://github.com/sct/overseerr/issues/1043)) ([594aad9](https://github.com/sct/overseerr/commit/594aad9d3ae9b323677f3af8c434d7664526593d))
+- **lang:** translations update from Weblate ([#1051](https://github.com/sct/overseerr/issues/1051)) ([69bf817](https://github.com/sct/overseerr/commit/69bf817f598babed99964f073259f827b60bd014))
+- **lang:** Translations update from Weblate ([#1131](https://github.com/sct/overseerr/issues/1131)) ([e4686d6](https://github.com/sct/overseerr/commit/e4686d664b52448e32488ff1c4236f72e01e9a29))
+- **notif:** add "Media Automatically Approved" notification type ([#1137](https://github.com/sct/overseerr/issues/1137)) ([f7d2723](https://github.com/sct/overseerr/commit/f7d2723fab2c30564fd23945709cd39b178a6eef))
+- **notif:** add settings for Discord bot username & avatar URL ([#1113](https://github.com/sct/overseerr/issues/1113)) ([3384eb1](https://github.com/sct/overseerr/commit/3384eb1c479114c0246cb22f9a933aa79fb95fcf))
+- **notif:** include poster image in Telegram notifications ([#1112](https://github.com/sct/overseerr/issues/1112)) ([48387e5](https://github.com/sct/overseerr/commit/48387e5b2f26c0c33acd436c6e1cf902d6c32101))
+- **scan:** add support for new plex tv agent ([#1144](https://github.com/sct/overseerr/issues/1144)) ([a51d2a2](https://github.com/sct/overseerr/commit/a51d2a24d51d092a0c6da608e3322f19a37c2d28))
+- **ui:** add user ID to profile header ([6e95c8b](https://github.com/sct/overseerr/commit/6e95c8b7a10e3467bfd2c3df84ccf886fe01ca5c))
+- add genre/studio/network view to Discover results ([#1067](https://github.com/sct/overseerr/issues/1067)) ([f28112f](https://github.com/sct/overseerr/commit/f28112f057df2589f31ae0d0b14e8b50e479fdb7))
+- add language-filtered Discover pages ([#1111](https://github.com/sct/overseerr/issues/1111)) ([7501161](https://github.com/sct/overseerr/commit/75011610e57f03098c8be9375d0c9ba1e3647e9b))
+- add studio/network sliders to discover ([1c6914f](https://github.com/sct/overseerr/commit/1c6914f5ce5c0d171c4609813915b50233a8e3ad))
+- **telegram:** add support for individual chat notifications ([#1027](https://github.com/sct/overseerr/issues/1027)) ([f6d00d8](https://github.com/sct/overseerr/commit/f6d00d8d1559879189f83739193c6e2acafde51d))
+- **ui:** display "Owner" role instead of "Admin" for user ID 1 ([#1050](https://github.com/sct/overseerr/issues/1050)) ([1b55d2d](https://github.com/sct/overseerr/commit/1b55d2dfbc06d900e7370a4ddfd81789a25bf00c))
+- **ui:** display season count on TV details page ([#1078](https://github.com/sct/overseerr/issues/1078)) ([4365231](https://github.com/sct/overseerr/commit/436523139e8f1594c352b17032734b4498d3994f))
+- **ui:** in Settings > Services, make Radarr/Sonarr server names and logos clickable links ([#1008](https://github.com/sct/overseerr/issues/1008)) ([6a1e389](https://github.com/sct/overseerr/commit/6a1e3891aa5f84b6adb1e475a6658a8cd4e34c22))
+- **ui:** request list redesign ([#1099](https://github.com/sct/overseerr/issues/1099)) ([cd21865](https://github.com/sct/overseerr/commit/cd21865c4d5be00c13c372e0b7a058f61ec855a2))
+
+## [1.20.1](https://github.com/sct/overseerr/compare/v1.20.0...v1.20.1) (2021-02-28)
+
+### Bug Fixes
+
+- **notif:** escape application title in Telegram notifications ([#1012](https://github.com/sct/overseerr/issues/1012)) ([5560abf](https://github.com/sct/overseerr/commit/5560abf459b0350ff30b5e71d4208418fc8f3b3e))
+- **notif:** fixed typo in pushover hint ([#1029](https://github.com/sct/overseerr/issues/1029)) ([e9f2fe9](https://github.com/sct/overseerr/commit/e9f2fe910d72fa41bc27673ed43291211c3cac65))
+- **notifications:** correctly send tv auto approval notifications ([537850f](https://github.com/sct/overseerr/commit/537850f414a88df24c78794a2fd68e1e24ff73d1)), closes [#1041](https://github.com/sct/overseerr/issues/1041)
+- **plex-sync:** no longer incorrectly sets 4k availability when there isnt any ([3f9a116](https://github.com/sct/overseerr/commit/3f9a116b17d78eeb04f0f125a4f3af6f907c83dd)), closes [#990](https://github.com/sct/overseerr/issues/990)
+- **ui:** for server default options, display "All" region/language option instead of empty string ([#1042](https://github.com/sct/overseerr/issues/1042)) ([3fed26c](https://github.com/sct/overseerr/commit/3fed26cfbe74cb662ca531fd37b69f159a051ac1))
+- **ui:** show translated string on sonarr sucesss/failure toast messages ([#1035](https://github.com/sct/overseerr/issues/1035)) ([eefcbcd](https://github.com/sct/overseerr/commit/eefcbcd3ddfa5258ee24dbbbd79de5bf50310f27))
+- **ui:** use country-flag-icons instead of country-flag-emoji for RegionSelector ([#1011](https://github.com/sct/overseerr/issues/1011)) ([abcd7c9](https://github.com/sct/overseerr/commit/abcd7c997584c1310bd8b313ac38f30e335af8d7))
+- add missing default value for settings context ([084917f](https://github.com/sct/overseerr/commit/084917f02d399e2d29bb9927e033c2e6533f586c))
+- added missing language default for ssr context defaults ([9ce88ab](https://github.com/sct/overseerr/commit/9ce88abcc85d744d77172cd2357fdb4ff60dc5e4))
+- allow users to override language/region settings ([69294a7](https://github.com/sct/overseerr/commit/69294a7c4c5bbe55c5cd276786cdfd48ddbff889)), closes [#1013](https://github.com/sct/overseerr/issues/1013)
+
+# [1.20.0](https://github.com/sct/overseerr/compare/v1.19.1...v1.20.0) (2021-02-23)
+
+### Bug Fixes
+
+- **api:** add isAuthenticated middleware to base user route ([8a27c70](https://github.com/sct/overseerr/commit/8a27c7062599ea23dca115e6e6e95a594e1b219a))
+- **api:** sort users requests by most recent ([1798383](https://github.com/sct/overseerr/commit/17983837fc10661a59d29fc1531530fca0d77825))
+- **api:** Use POST instead of GET for API endpoints that mutate state ([#877](https://github.com/sct/overseerr/issues/877)) ([ff0b5ed](https://github.com/sct/overseerr/commit/ff0b5ed44132cc5a0cd178035796d042ba735a8d))
+- **auth:** handle sign-in attempts from emails with no password ([#933](https://github.com/sct/overseerr/issues/933)) ([5e37a96](https://github.com/sct/overseerr/commit/5e37a96bc017471f8dc4cbdd57f2e8c3568bd97f))
+- **frontend:** changed plex, request, and cog buttons to align properly on smaller mobile UIs ([#928](https://github.com/sct/overseerr/issues/928)) ([f1c3358](https://github.com/sct/overseerr/commit/f1c335815f2f17465cdd36ceb223e78a58149b3b))
+- **frontend:** check for id instead of email after logging in ([c4af4c4](https://github.com/sct/overseerr/commit/c4af4c42ab00f1a63a2f5326c9cd8b26c19f4f14))
+- **frontend:** Do not allow user w/ ID 1 to disable 'Admin' permission ([#965](https://github.com/sct/overseerr/issues/965)) ([77b2d9e](https://github.com/sct/overseerr/commit/77b2d9ea22a2f70cff58ac9421f3f6231bc93059))
+- **frontend:** handle empty array of media attributes ([#922](https://github.com/sct/overseerr/issues/922)) ([04fa9f7](https://github.com/sct/overseerr/commit/04fa9f79e2ec90082b3fa15590dd170f7d68ad52))
+- **frontend:** request and cog button would be misaligned without play on plex/watch trailer button ([#956](https://github.com/sct/overseerr/issues/956)) ([e28dfad](https://github.com/sct/overseerr/commit/e28dfadaf57d47887013c31dc5006332473156e3))
+- **frontend:** Update AdvancedRequester to reflect new /user API response ([#970](https://github.com/sct/overseerr/issues/970)) ([b4bac6a](https://github.com/sct/overseerr/commit/b4bac6a9157119a4f234933245944e133c127bd0))
+- **frontend:** use region settings instead of hardcoded 'US' value for movie/TV ratings ([#1006](https://github.com/sct/overseerr/issues/1006)) ([6ecd202](https://github.com/sct/overseerr/commit/6ecd202607cb48d559440da810ecc585e740542b))
+- **lang:** formatMessage should not use an object spread ([8a7fa00](https://github.com/sct/overseerr/commit/8a7fa00164fd5c5501da525baa29be97bac7e7c4))
+- **lang:** Remove unused strings and correct spelling of 'canceling'/'canceled' ([#981](https://github.com/sct/overseerr/issues/981)) ([5b64655](https://github.com/sct/overseerr/commit/5b646557765d1ad75e44e1c0e60e0291313c7746))
+- **login:** fix the gap when 'use your overseer account' was selected ([#870](https://github.com/sct/overseerr/issues/870)) ([d163e29](https://github.com/sct/overseerr/commit/d163e294599c4bd9bdc0a148db15c8e8541410d8))
+- **notif:** Do not HTML-escape email subjects ([#931](https://github.com/sct/overseerr/issues/931)) ([019622a](https://github.com/sct/overseerr/commit/019622aab1b94cc4d71cacbf0dc5cf64b62c8623))
+- **notif:** Remove extra newlines from Telegram notifications ([#973](https://github.com/sct/overseerr/issues/973)) ([bbea522](https://github.com/sct/overseerr/commit/bbea52249950eb98a8d3886f2bd7648a7d669bf4))
+- **plex:** Check Plex server access on user import ([#955](https://github.com/sct/overseerr/issues/955)) ([bdb3cb2](https://github.com/sct/overseerr/commit/bdb3cb202550e34d8951ac2b5015f97f6a5c1ebf))
+- **plex-sync:** get correct Plex metadata for Hama movie items ([#901](https://github.com/sct/overseerr/issues/901)) ([03cecb3](https://github.com/sct/overseerr/commit/03cecb33559e27199c5a174fc86de0c4550fe666)), closes [#898](https://github.com/sct/overseerr/issues/898)
+- **requests:** correctly filter requests out for users without view requests permission ([e118501](https://github.com/sct/overseerr/commit/e118501bf1dfa8dada2c57090e62631de620f3dd))
+- **requests:** correctly handle when tvdbid is missing ([#891](https://github.com/sct/overseerr/issues/891)) ([e037ba4](https://github.com/sct/overseerr/commit/e037ba48f173c06b0c9c8b03085edf832d770c06))
+- **search:** Handle search errors and escape \* ([#893](https://github.com/sct/overseerr/issues/893)) ([034968e](https://github.com/sct/overseerr/commit/034968e4370eaea726c94730274349c083856813))
+- **services:** update all radarr/sonarr endpoints to use v3 ([da5ca02](https://github.com/sct/overseerr/commit/da5ca02f81fe91070afbda3e1ebc8d869fe39a8f))
+- **sonarr:** use qualityProfileId instad of profileId when adding series ([552a7e3](https://github.com/sct/overseerr/commit/552a7e30da5fc2cc0bd43b5aef79a0225c75d233))
+- **sync:** fix sonarr/plex sync fighting over availability ([9b73423](https://github.com/sct/overseerr/commit/9b73423d49e1e799cd82764a9ade8c75d92a28a2)), closes [#872](https://github.com/sct/overseerr/issues/872)
+- **ui:** add fallback for region display name ([f9c83e1](https://github.com/sct/overseerr/commit/f9c83e14e52a57d6865307b3324a61c04a77a541))
+- **ui:** add missing string for default Discover Language & edit string for default Discover Region ([#1004](https://github.com/sct/overseerr/issues/1004)) ([0acad8e](https://github.com/sct/overseerr/commit/0acad8e9fa65a9de6cecac9b6a4a5b2313ba8f06))
+- **ui:** Add tip & validation for Discord ID input ([#966](https://github.com/sct/overseerr/issues/966)) ([e70a4ec](https://github.com/sct/overseerr/commit/e70a4ecae613e045977e262fd7f9643f30985ab7))
+- **ui:** also allow 17 digit discord ids ([57c00c1](https://github.com/sct/overseerr/commit/57c00c1ea71c1229d5a59e1b8dadd84a646772b9)), closes [#971](https://github.com/sct/overseerr/issues/971)
+- **ui:** Automatically disable and uncheck user permissions with unmet requirements ([#941](https://github.com/sct/overseerr/issues/941)) ([c9a150b](https://github.com/sct/overseerr/commit/c9a150b1db2adbb305cf1a448489d7a8c14cf1cb))
+- **ui:** change font size in request list/user list dropdowns to prevent zoom on mobile ([fb9c878](https://github.com/sct/overseerr/commit/fb9c878db49c01d13773e8d2f94c93f840be0b82))
+- **ui:** Display 4K download status on 4K status badge ([#988](https://github.com/sct/overseerr/issues/988)) ([40b07c3](https://github.com/sct/overseerr/commit/40b07c35d40c03039e4bfa5ed1e73af7e8aa6a7d))
+- **ui:** Fix card sizes on person detail pages ([#881](https://github.com/sct/overseerr/issues/881)) ([a3042f8](https://github.com/sct/overseerr/commit/a3042f8e1b05a91d98f48a4aecb08e831a48fc56))
+- **ui:** Fix settings navigation horizontal scroll issues ([#987](https://github.com/sct/overseerr/issues/987)) ([8701fb2](https://github.com/sct/overseerr/commit/8701fb20d07773f4cc32e857b68575a813cf7e21))
+- **ui:** fix webhook URL validation regex ([baad19a](https://github.com/sct/overseerr/commit/baad19a2c94728313ee996fe1a0ffc64fbd9aaa3))
+- **ui:** fixed anime language profile typo ([#879](https://github.com/sct/overseerr/issues/879)) ([ee50761](https://github.com/sct/overseerr/commit/ee5076146ef3c5e8baba197a5b397d3c3f575262))
+- **ui:** Handle missing movie/series data ([#862](https://github.com/sct/overseerr/issues/862)) ([7c0ddad](https://github.com/sct/overseerr/commit/7c0ddad653393327226a877692f046d8693ddc66))
+- **ui:** Notification-related string/UI edits and field validation ([#985](https://github.com/sct/overseerr/issues/985)) ([c88fcb2](https://github.com/sct/overseerr/commit/c88fcb2e2d1c4b84527844a80680c15337626e72))
+- **ui:** rename global group class to form-group ([8056187](https://github.com/sct/overseerr/commit/8056187c3c0ea464a8f751aa6347ea1d35c01aac))
+- **ui:** Size cards appropriately based on base font size ([#871](https://github.com/sct/overseerr/issues/871)) ([282f28f](https://github.com/sct/overseerr/commit/282f28f2b9d0cc8c9105d01b43d4e1f730320b8b))
+- **ui/notif:** Custom application title in password-related emails and UI messages ([#979](https://github.com/sct/overseerr/issues/979)) ([4e2706b](https://github.com/sct/overseerr/commit/4e2706b4211b06f364910c327d84c2ceb45b2fe3))
+
+### Features
+
+- **lang:** translated using Weblate (French) ([#1007](https://github.com/sct/overseerr/issues/1007)) ([970da66](https://github.com/sct/overseerr/commit/970da664b2700b8cd9ad8dce0cbca1d37820eceb))
+- **lang:** translations update from Weblate ([#853](https://github.com/sct/overseerr/issues/853)) ([e156acc](https://github.com/sct/overseerr/commit/e156acc1ae2fa86b4441faacc0b58e1e993e0edc))
+- **lang:** translations update from Weblate ([#986](https://github.com/sct/overseerr/issues/986)) ([4296765](https://github.com/sct/overseerr/commit/4296765ad61bac09c2317b71b763366d328733e4))
+- **notif:** Add Pushbullet notification agent ([#950](https://github.com/sct/overseerr/issues/950)) ([29b97ef](https://github.com/sct/overseerr/commit/29b97ef6d85bbea31dd59b7ad857b0d8ab30bff0))
+- **notif:** Notification improvements ([#914](https://github.com/sct/overseerr/issues/914)) ([2768155](https://github.com/sct/overseerr/commit/2768155bbabe121a4c51fc1472461cd5114c4300))
+- **regions:** add region/original language setting for filtering Discover ([#732](https://github.com/sct/overseerr/issues/732)) ([#942](https://github.com/sct/overseerr/issues/942)) ([b557c06](https://github.com/sct/overseerr/commit/b557c06b0a78f5df5f64a05dc1e4511dae72df4f))
+- **requests:** add language profile support ([#860](https://github.com/sct/overseerr/issues/860)) ([53f6f59](https://github.com/sct/overseerr/commit/53f6f59798fa7e3f95959990a3df555db3c1c51e))
+- **ui:** Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value ([#905](https://github.com/sct/overseerr/issues/905)) ([9757e3a](https://github.com/sct/overseerr/commit/9757e3ae0c572fb46177e25154b29e0ceced665f))
+- **ui:** Add 'Page Size' setting for request/user list pages ([#957](https://github.com/sct/overseerr/issues/957)) ([621db89](https://github.com/sct/overseerr/commit/621db893281f0280fe773ac7dbdc44434895242c))
+- **ui:** Add separate permissions for 4K auto approval ([#908](https://github.com/sct/overseerr/issues/908)) ([53b7425](https://github.com/sct/overseerr/commit/53b7425f6711e250935e7bb024c38ff6c62e07d9))
+- **ui:** Add sort options to user list ([#913](https://github.com/sct/overseerr/issues/913)) ([ef5d019](https://github.com/sct/overseerr/commit/ef5d019c18d7f6cdbbb1e1b7f8ff7816ed9b117b))
+- **ui:** Add support for requesting collections in 4K ([#968](https://github.com/sct/overseerr/issues/968)) ([139341b](https://github.com/sct/overseerr/commit/139341b0434b41e7c31af36baacd8d65566a6a0c))
+- user profile/settings pages ([#958](https://github.com/sct/overseerr/issues/958)) ([bbb683e](https://github.com/sct/overseerr/commit/bbb683e637386ad8bbeb44dca97aac9cdaf11349))
+- **ui:** added content ratings for tv shows and movie ratings ([#878](https://github.com/sct/overseerr/issues/878)) ([c8b2a57](https://github.com/sct/overseerr/commit/c8b2a57721a51adcc7f90ec1acb48b127991d467))
+- **users:** add reset password flow ([#772](https://github.com/sct/overseerr/issues/772)) ([e5966bd](https://github.com/sct/overseerr/commit/e5966bd3fbfe172f264f4e986ad2aecf29ae1510))
+
+## [1.19.1](https://github.com/sct/overseerr/compare/v1.19.0...v1.19.1) (2021-02-06)
+
+### Bug Fixes
+
+- **ui:** Fix webhook URL validation regex ([#864](https://github.com/sct/overseerr/issues/864)) ([726f62b](https://github.com/sct/overseerr/commit/726f62b9b69b5078e718f129e26abdf358f5cb06))
+
+# [1.19.0](https://github.com/sct/overseerr/compare/v1.18.0...v1.19.0) (2021-02-05)
+
+### Bug Fixes
+
+- **api:** filter out adult content from combined credits ([3052f12](https://github.com/sct/overseerr/commit/3052f12c91b3ce86128324e3698fff61bbce3f2a))
+- **cache:** use formatted numbers for displaying cache counts ([6c437c5](https://github.com/sct/overseerr/commit/6c437c515fc01b9fe4461968875e23542bae7542))
+- **email:** make image a link to the action url in request template ([ee0a7bd](https://github.com/sct/overseerr/commit/ee0a7bd8c0b3a79c292b0abceb2f780f3889e49f)), closes [#834](https://github.com/sct/overseerr/issues/834)
+- **frontend:** add github sponsor link to about page ([7c192d5](https://github.com/sct/overseerr/commit/7c192d54f422a5f2b55750535d2382e313f1d011))
+- **frontend:** correctly show 4k download tracker activity ([a7314f8](https://github.com/sct/overseerr/commit/a7314f876ea528fdec0fb0a2adaa36a01afcdf38))
+- **frontend:** fix possible division by zero in download status ([#839](https://github.com/sct/overseerr/issues/839)) ([c97c96a](https://github.com/sct/overseerr/commit/c97c96a30c50db7735f06c6d2d2f6193fb7da55e))
+- **frontend:** match request button color on titlecards to other request buttons ([5b39911](https://github.com/sct/overseerr/commit/5b39911e024513fab7a62948e653cee08fd166c7))
+- **frontend:** set 4k status on RequestItem when request is for 4k ([a3b00c3](https://github.com/sct/overseerr/commit/a3b00c3458b868506d4158fb24f0369fa5daefc5))
+- **frontend:** use consistent spinner style on TitleCard/Plex Presets ([cf7ebc4](https://github.com/sct/overseerr/commit/cf7ebc488db33725444c428b4244d780ab9d123b))
+- **html:** th elements should be nested under tr, not directly under thead ([#801](https://github.com/sct/overseerr/issues/801)) ([6e9ac27](https://github.com/sct/overseerr/commit/6e9ac275e19d56de8c7a366db970c7321f26fc8a))
+- **lang:** Add missing source strings & remove local user sign-in setting tip ([#828](https://github.com/sct/overseerr/issues/828)) ([c0769d4](https://github.com/sct/overseerr/commit/c0769d4f8f2bad88e4638d8c3cbcc0414b3ef6fb))
+- **lang:** Edit English language strings ([#820](https://github.com/sct/overseerr/issues/820)) ([f54df21](https://github.com/sct/overseerr/commit/f54df214af86d90ea8d7cfcd4e39022215c3568c))
+- **lang:** translate language names & change zh-Hant language code to zh-TW ([#793](https://github.com/sct/overseerr/issues/793)) ([3c5ae36](https://github.com/sct/overseerr/commit/3c5ae360fd179d794a78cc918fe97a09216ca6b2))
+- **notif/ui:** Use custom application title in notifications & sign-in page ([#849](https://github.com/sct/overseerr/issues/849)) ([38c76b5](https://github.com/sct/overseerr/commit/38c76b55e0039c489cb6a4a0a298aa6385406db4))
+- **radarr:** correctly set requested status after sending to radarr (with auto approve) ([ec44841](https://github.com/sct/overseerr/commit/ec448413569ddc2f24bb856d29084169979f9f05))
+- **sonarr-sync:** sonarr sync will no longer set shows with no episodes to partially available ([d20bd53](https://github.com/sct/overseerr/commit/d20bd530edaadc5887b0361358da80153e36505c)), closes [#796](https://github.com/sct/overseerr/issues/796)
+- **ui:** Add additional URL & email input validation ([#843](https://github.com/sct/overseerr/issues/843)) ([3f9bfeb](https://github.com/sct/overseerr/commit/3f9bfeb01a67b2b587c7548b02ee826722e65c0f))
+- **ui:** Don't display empty dropdown when no trailer available ([#804](https://github.com/sct/overseerr/issues/804)) ([95c2a21](https://github.com/sct/overseerr/commit/95c2a2169799d96413b47ab24506b330435643eb))
+- **ui:** dont show bulk edit options on user list if there is only one user ([b658ddf](https://github.com/sct/overseerr/commit/b658ddf5cf61b2bb9b93cb1a4ca716cd75e18bb4))
+- **ui:** Dynamically generate path to config in warning message ([#851](https://github.com/sct/overseerr/issues/851)) ([b531a64](https://github.com/sct/overseerr/commit/b531a642f601f4ef9bf39c2f5915402157e55372))
+- **ui:** fix tables extending outside viewport in mobile formats ([e270999](https://github.com/sct/overseerr/commit/e270999745f97c2860f6a5b84e897dc6da8d6001))
+- **ui:** Hide 'Mark 4k as Available' button if 4k not enabled ([#833](https://github.com/sct/overseerr/issues/833)) ([e4a50c3](https://github.com/sct/overseerr/commit/e4a50c33f105b440243885d72a9e96595a525447))
+- **ui:** Limit max width of forms & lists ([#845](https://github.com/sct/overseerr/issues/845)) ([b9d14a9](https://github.com/sct/overseerr/commit/b9d14a9fd0f3c94d8267755147a87fe3b77fa2c3))
+- **ui:** prevent names from getting squished in AdvancedRequester user selector ([06e9411](https://github.com/sct/overseerr/commit/06e941171a1d019fbb178624167c026f6df5271c))
+- **ui:** remove yup validation from display name on user edit page ([63d7e2b](https://github.com/sct/overseerr/commit/63d7e2b39858fcb1cc0819a680eebccded7f4451))
+- **ui:** Restore original port input size ([#814](https://github.com/sct/overseerr/issues/814)) ([1ccafc0](https://github.com/sct/overseerr/commit/1ccafc0ebd368d798f9571b83910336efa317e37))
+- **ui:** show request as option even if there are no radarr/sonarr servers ([b116281](https://github.com/sct/overseerr/commit/b116281196c264b4ec35b07f1b4ffa717e50ade5))
+- **ui:** uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies ([#737](https://github.com/sct/overseerr/issues/737)) ([e34fbf7](https://github.com/sct/overseerr/commit/e34fbf72fda34d69b9f25563fa81f88b3c20912a))
+- **ui:** Use minimum char validation message ([#850](https://github.com/sct/overseerr/issues/850)) ([7456bea](https://github.com/sct/overseerr/commit/7456bea2ae600a28cb933278ffb310b63a474d6a))
+- **ui:** validate application url and service external urls ([026795d](https://github.com/sct/overseerr/commit/026795d4c940cb4797d3e68089456a4c3defbb21))
+- **ui:** when PersonCard has no profilePath, correctly position name/role content ([3ffd5ab](https://github.com/sct/overseerr/commit/3ffd5ab0ee8ffa63199d1428e37206f9b59fb7a5))
+
+### Features
+
+- **cache:** add cache table and flush cache option to settings ([996bd9f](https://github.com/sct/overseerr/commit/996bd9f14ed0f56767892c169b071be4f0f628d0))
+- **cache:** external API cache ([#786](https://github.com/sct/overseerr/issues/786)) ([20289b5](https://github.com/sct/overseerr/commit/20289b5960a93545cdff9331a1a7b613f382e702))
+- **docker:** Check for /app/config volume mount during setup ([#826](https://github.com/sct/overseerr/issues/826)) ([1e5f88f](https://github.com/sct/overseerr/commit/1e5f88f462b0c69db5f6ab8e0249a5905bc6952a))
+- **frontend:** add TheTVDB external link ([#800](https://github.com/sct/overseerr/issues/800)) ([72cffd7](https://github.com/sct/overseerr/commit/72cffd74a75984ba98c456c0ec006ec378a8dcec))
+- **lang:** add support for Hungarian language ([cfacb15](https://github.com/sct/overseerr/commit/cfacb151b52d08e19d2fcd603fb4bbcd78707cdf))
+- **lang:** translations update from Weblate ([#791](https://github.com/sct/overseerr/issues/791)) ([42295e0](https://github.com/sct/overseerr/commit/42295e076a7579b226d57407a20cb0ba044e9ec1))
+- **lang:** translations update from Weblate ([#819](https://github.com/sct/overseerr/issues/819)) ([9e5e4c2](https://github.com/sct/overseerr/commit/9e5e4c22f5b25df96f47875d599ed8685791382a))
+- **lang:** translations update from Weblate ([#841](https://github.com/sct/overseerr/issues/841)) ([e4f9b8a](https://github.com/sct/overseerr/commit/e4f9b8a9848f3af00e86fc7108c823ed0584609f))
+- **lang:** translations update from Weblate ([#852](https://github.com/sct/overseerr/issues/852)) ([c5be00e](https://github.com/sct/overseerr/commit/c5be00eebfd2b0e65295edbe282cbba22fffa660))
+- **ui:** Add local login setting ([#817](https://github.com/sct/overseerr/issues/817)) ([9d0d5b8](https://github.com/sct/overseerr/commit/9d0d5b86aae025e4647bb664c6412d42192e2fe7))
+- **ui:** added next airing date to TV Shows ([#842](https://github.com/sct/overseerr/issues/842)) ([4eae02a](https://github.com/sct/overseerr/commit/4eae02a7e14e377fd69ddd4a43774cb7e3d1855b))
+- new permission to allow users to see other users requests ([033ba9d](https://github.com/sct/overseerr/commit/033ba9d41bddf6dc1c4512d8404f747e57923bca)), closes [#840](https://github.com/sct/overseerr/issues/840)
+- request as another user ([59150f9](https://github.com/sct/overseerr/commit/59150f955f7003672ef19eb9d37156e93b79c97d))
+- **tv:** show cast for the entire show instead of only the last season ([#778](https://github.com/sct/overseerr/issues/778)) ([b239598](https://github.com/sct/overseerr/commit/b239598e64d33b78dc5d7972878840149aff360a)), closes [#775](https://github.com/sct/overseerr/issues/775)
+- **ui:** Add custom title functionality ([#825](https://github.com/sct/overseerr/issues/825)) ([35c6bfc](https://github.com/sct/overseerr/commit/35c6bfc0216bf879353b3ee546b439a06c8e6121))
+
+# [1.18.0](https://github.com/sct/overseerr/compare/v1.17.2...v1.18.0) (2021-01-30)
+
+### Bug Fixes
+
+- **api:** prevent duplicate movie requests ([421f4c1](https://github.com/sct/overseerr/commit/421f4c17f0f206bbe7bfcbf2819014b8c7f55b6a)), closes [#705](https://github.com/sct/overseerr/issues/705)
+- **build:** fix sqlite3 build error ([#691](https://github.com/sct/overseerr/issues/691)) ([3a1f6d5](https://github.com/sct/overseerr/commit/3a1f6d5706c8fc100e88425f3d89a26a0325af79))
+- **frontend:** add poster not found image to request card and request list item ([ae9a1b3](https://github.com/sct/overseerr/commit/ae9a1b3e940ac2abf6e842d91f458daab3dd0f0d))
+- **frontend:** add poster not found image to tv details page ([0b05545](https://github.com/sct/overseerr/commit/0b055458d0ddbfd4c87ebf9b0562f161fa3445a3))
+- **frontend:** dont show external links unless slug is set ([946bd2d](https://github.com/sct/overseerr/commit/946bd2db5ecde0748b2e9bc5edfe7ca6000ec3d5))
+- **frontend:** fix server name position on plex settings page ([86efcd8](https://github.com/sct/overseerr/commit/86efcd82c34ad6490f2899ebf6f84cdd1bffc498))
+- **frontend:** fixed mismatched rounded sizing on new login ([5e352c2](https://github.com/sct/overseerr/commit/5e352c201fc2f731ca5f713ecb6901527ef354da)), closes [#721](https://github.com/sct/overseerr/issues/721)
+- **ip logging:** add env var for proxy to fix ip logging on failed logins ([#756](https://github.com/sct/overseerr/issues/756)) ([9342a40](https://github.com/sct/overseerr/commit/9342a40bbc03f7fdda23e3876b3a4a81ea8532c0))
+- **lang:** add missing i18n strings for notification settings ([2f75c4c](https://github.com/sct/overseerr/commit/2f75c4c6aed42a15bb47d3652272de8f852ec79f))
+- **notifications:** only send a single notification when standard media becomes available ([b5fd1d5](https://github.com/sct/overseerr/commit/b5fd1d520cd2a7be6e6356a25129e93af1caf542)), closes [#770](https://github.com/sct/overseerr/issues/770)
+- **permissions:** use default user permissions when creating a local user ([#713](https://github.com/sct/overseerr/issues/713)) ([660ada0](https://github.com/sct/overseerr/commit/660ada0b2025eb2c06d9054fd0a7b5a632af6af2))
+- **radarr:** fix request bug which made it unable to be added to radarr ([#760](https://github.com/sct/overseerr/issues/760)) ([45a2779](https://github.com/sct/overseerr/commit/45a277964b0c39346d7216873812e0ebe505cb79))
+- **radarr:** return the updated data when updating radarr request ([#765](https://github.com/sct/overseerr/issues/765)) ([0c6d478](https://github.com/sct/overseerr/commit/0c6d4780c355ffe1a951268fb6949491d435bbf1))
+- **requests:** handle when tvdbid is null ([#657](https://github.com/sct/overseerr/issues/657)) ([2da0da8](https://github.com/sct/overseerr/commit/2da0da826ae1d73467bc8a671fda7cc5ca1f14c9))
+- **sonarr-sync:** correctly set series with no seasons to requested status ([3812989](https://github.com/sct/overseerr/commit/3812989a1ce1e07d4af09149008043a6e2e94060)), closes [#762](https://github.com/sct/overseerr/issues/762)
+- **sync:** do not update series status if already available and no new seasons ([136d874](https://github.com/sct/overseerr/commit/136d874cba37babf9c0670844b002871710e6d99)), closes [#777](https://github.com/sct/overseerr/issues/777)
+- **ui:** Capitalization, punctuation, and grammar inconsistences & errors ([#731](https://github.com/sct/overseerr/issues/731)) ([f05d4a0](https://github.com/sct/overseerr/commit/f05d4a0d0b42905fcaee49b2471bb1f4ee77fffe))
+- lookup movie by imdbid if tmdbid does not exits for plex movie agent ([#711](https://github.com/sct/overseerr/issues/711)) ([e972288](https://github.com/sct/overseerr/commit/e97228899a5936b2525c8060abfa14b5ce31658d))
+- show recently added series even if they are not complete ([d0c830e](https://github.com/sct/overseerr/commit/d0c830e80d389f9e0f48a9b83659331f54630d03))
+
+### Features
+
+- **lang:** translated using Weblate (Dutch) ([059995e](https://github.com/sct/overseerr/commit/059995e0ef3370a3192bd386fa6875ca0f58690a))
+- **lang:** translated using Weblate (French) ([4789583](https://github.com/sct/overseerr/commit/4789583d66305ac7b3d393659b2f3604c0acc576))
+- **lang:** translations update from Weblate ([#727](https://github.com/sct/overseerr/issues/727)) ([71875ef](https://github.com/sct/overseerr/commit/71875efb48246dbb0139ad15a4261a5661fcfe17))
+- **lang:** update languages and fix merge conflict ([083a74a](https://github.com/sct/overseerr/commit/083a74a686d202cce5775bf9752caaa9a626cf45))
+- **ui:** Move PROXY setting to UI ([#782](https://github.com/sct/overseerr/issues/782)) ([f1dd5e7](https://github.com/sct/overseerr/commit/f1dd5e7e12c1f602449c4769173dbce71e3569d0))
+- add manual availability buttons to manage slideover ([67f8aef](https://github.com/sct/overseerr/commit/67f8aef00d98c834b60cb6152ccd5cb7b5709d12)), closes [#672](https://github.com/sct/overseerr/issues/672)
+- **media:** add link to the item on plex ([#735](https://github.com/sct/overseerr/issues/735)) ([1d7150c](https://github.com/sct/overseerr/commit/1d7150c24ec5ad347093889bfceab61b664900d5))
+- Radarr & Sonarr Sync ([#734](https://github.com/sct/overseerr/issues/734)) ([ec5fb83](https://github.com/sct/overseerr/commit/ec5fb836785855eb4846fd33b49faeb94c40506a))
+- **frontend:** add option to hide all available items from discovery ([#699](https://github.com/sct/overseerr/issues/699)) ([6c1742e](https://github.com/sct/overseerr/commit/6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae))
+- **lang:** add support for Portuguese (Portugal) language ([e044146](https://github.com/sct/overseerr/commit/e044146aa55109a1eccfde9650b26beb0d5ec9a6))
+- **lang:** translated using Weblate (Dutch) ([6d0f7d4](https://github.com/sct/overseerr/commit/6d0f7d4b50370c420c1017f32d48313074543743))
+- **lang:** translated using Weblate (Italian) ([9aa5c12](https://github.com/sct/overseerr/commit/9aa5c121644518c1fbb308a487c26d8998bb5a36))
+- **lang:** translated using Weblate (Portuguese (Portugal)) ([f001fb3](https://github.com/sct/overseerr/commit/f001fb3b33d4fb749acb70c45b8a55a5bbef570c))
+- **lang:** translated using Weblate (Spanish) ([4f94d22](https://github.com/sct/overseerr/commit/4f94d227fc3096bcb8a1e5cf12fe9222d6c6b711))
+- **login:** add request ip to the failed request log ([#714](https://github.com/sct/overseerr/issues/714)) ([2d31ea9](https://github.com/sct/overseerr/commit/2d31ea940ac0a1a84d2150743798b41ff6490317))
+- **users:** add editable usernames ([#715](https://github.com/sct/overseerr/issues/715)) ([20ca3f2](https://github.com/sct/overseerr/commit/20ca3f2f5fcf4a9eb0d6a8be671bb4fb1f5e6178))
+- pre-populate server info from plex.tv API ([#563](https://github.com/sct/overseerr/issues/563)) ([82ac76b](https://github.com/sct/overseerr/commit/82ac76b0540ba1133cb5384744d2499c2488a4e8))
+- **auth:** Add optional CSRF protection ([#697](https://github.com/sct/overseerr/issues/697)) ([6e25891](https://github.com/sct/overseerr/commit/6e2589178b99f8f32f0ded9a7cfd9921c33e9b60))
+- ability to edit user settings in bulk ([#597](https://github.com/sct/overseerr/issues/597)) ([4b0241c](https://github.com/sct/overseerr/commit/4b0241c3b34d4229f928c21defb10a1c051264d1))
+- **lang:** translated using Weblate (English) ([9bb11af](https://github.com/sct/overseerr/commit/9bb11afc6b4a109ae1e14d41c9fe2b71f19c470a))
+- **lang:** translated using Weblate (German) ([c2a3e8e](https://github.com/sct/overseerr/commit/c2a3e8ed5243925dce991ec7995ae831702dbc7b))
+- **lang:** translated using Weblate (Portuguese (Brazil)) ([32f4916](https://github.com/sct/overseerr/commit/32f4916c4a926097f31ed472aee031536b847bb7))
+- **lang:** translated using Weblate (Portuguese (Brazil)) ([98570c9](https://github.com/sct/overseerr/commit/98570c920e4904a594bb7464161b985094958f84))
+- **notifications:** add option to send notifications for auto-approved requests ([21db367](https://github.com/sct/overseerr/commit/21db3676d1464b63384b04c0c2926cb2a6252e9b)), closes [#267](https://github.com/sct/overseerr/issues/267)
+
+## [1.17.2](https://github.com/sct/overseerr/compare/v1.17.1...v1.17.2) (2021-01-20)
+
+### Bug Fixes
+
+- **requests:** allow declined season requests to be re-requested ([e1032ff](https://github.com/sct/overseerr/commit/e1032ff5dfac4a8c9d4da9cf2788c19822343ad9)), closes [#690](https://github.com/sct/overseerr/issues/690)
+- **requests:** update requests to approved when parent media is set as available ([78444a9](https://github.com/sct/overseerr/commit/78444a9e643829823162389dee60cca70da56bff)), closes [#688](https://github.com/sct/overseerr/issues/688)
+
+## [1.17.1](https://github.com/sct/overseerr/compare/v1.17.0...v1.17.1) (2021-01-19)
+
+### Bug Fixes
+
+- **frontend:** show auto approval on series request modal only with correct permissions ([8927c6d](https://github.com/sct/overseerr/commit/8927c6d2e39dbda2b1121095a7273f5cab1c9b74)), closes [#687](https://github.com/sct/overseerr/issues/687)
+
+# [1.17.0](https://github.com/sct/overseerr/compare/v1.16.0...v1.17.0) (2021-01-19)
+
+### Bug Fixes
+
+- **api:** improve rottentomatoes rating matching for movies ([7db62ab](https://github.com/sct/overseerr/commit/7db62ab824eefc42e6db16e42d52f4266b136f82)), closes [#494](https://github.com/sct/overseerr/issues/494)
+- **build:** remove cross import from client to server for UserType ([23624bd](https://github.com/sct/overseerr/commit/23624bd144af5df4c31995b68ce48105b95b20f6))
+- **frontend:** clarify which fields are required in radarr/sonarr modals ([860d71e](https://github.com/sct/overseerr/commit/860d71ed69a69a1a3f74b79290ef471e04f57a6b)), closes [#575](https://github.com/sct/overseerr/issues/575)
+- **frontend:** do not show failed media status on request list for declined requests ([00944b1](https://github.com/sct/overseerr/commit/00944b1ec2db8ddc5742448f6448f7364c473a98)), closes [#664](https://github.com/sct/overseerr/issues/664)
+- **frontend:** fix button styling on details page on small screen sizes ([d9e0c90](https://github.com/sct/overseerr/commit/d9e0c90e76d80aef0c67318e00e997804805f46e))
+- **frontend:** fix request button height ([a262727](https://github.com/sct/overseerr/commit/a2627270784bdef8644875fa5c5a7349a0b7fd81))
+- **frontend:** request dropdown menu now properly shows up over collection button ([b491be1](https://github.com/sct/overseerr/commit/b491be1b1e7f6aa588274230e695e4c5302b961e))
+- **frontend:** show correct request status on request cards for 4k requests ([1aa0005](https://github.com/sct/overseerr/commit/1aa0005b4298fc1af9c1d0bf1f357738c0fa2673))
+- **lang:** add missing see more i18n string for SeeMoreCard ([d9919ab](https://github.com/sct/overseerr/commit/d9919abb8998d28558ddec35b8e60ab2af75d5b7))
+- **lang:** change email auth user/pass strings to SMTP Username/Password ([a77a2aa](https://github.com/sct/overseerr/commit/a77a2aa3ebb1be353d534db5b07647ac26c60e15))
+- **notifications:** correctly compare seasons before sending series notifications ([f17fa2a](https://github.com/sct/overseerr/commit/f17fa2a2db8144bac89936f588627e8dd37bf54a))
+- **notifications:** only send one available notification for standard media ([fc6f7cc](https://github.com/sct/overseerr/commit/fc6f7ccea586165a30022b6d5554911c66ece6df))
+- **notifications:** send media declined email ([eb6fc8a](https://github.com/sct/overseerr/commit/eb6fc8a19099469794d471db0b48a258c2866633)), closes [#679](https://github.com/sct/overseerr/issues/679)
+- **plex-sync:** improve plex sync error handling. add session id to fix stuck runs ([a740b07](https://github.com/sct/overseerr/commit/a740b07f06f892b72a651b928af28ce71cb495ee))
+- **plex-sync:** store plex added date and sort recently added by it ([d688a96](https://github.com/sct/overseerr/commit/d688a967596afcba9799b8133089bebb5add27cf))
+- **requests:** select the correct radarr/sonarr server when sending request to service ([e0d9f89](https://github.com/sct/overseerr/commit/e0d9f891e797c3839f976b75a871903b6f2e55f1))
+- **server:** support absolute paths for CONFIG_DIRECTORY ([51d8fba](https://github.com/sct/overseerr/commit/51d8fba9162b9e148a35ced69e7e035438c8b0f1))
+- **user edit:** fix user edit not being able to be saved ([#651](https://github.com/sct/overseerr/issues/651)) ([b04d00e](https://github.com/sct/overseerr/commit/b04d00ef509d6f13c1f9677b3f318331782c0086))
+
+### Features
+
+- **api:** /request/count endpoint ([#682](https://github.com/sct/overseerr/issues/682)) ([192cfd8](https://github.com/sct/overseerr/commit/192cfd8a8ea9ab942d5bb265d42050917a2f5a04))
+- **frontend:** add see more card to media sliders ([587e8db](https://github.com/sct/overseerr/commit/587e8db15e9c19b4c58406e3e4215d8bf87d8762))
+- **frontend:** add template variable help button to custom webhook settings page ([29c5bc4](https://github.com/sct/overseerr/commit/29c5bc40975e7ab0a2e08bb77294f164f0c60769))
+- **lang:** add support for Chinese (Traditional) language ([686c4f7](https://github.com/sct/overseerr/commit/686c4f71bf930625af082ac5e14dc5f79f5c42eb))
+- **lang:** Translations update from Weblate ([#604](https://github.com/sct/overseerr/issues/604)) ([801e765](https://github.com/sct/overseerr/commit/801e76524d6ea0887249f1630402e9c3a3430b44))
+- **login:** add local users functionality ([#591](https://github.com/sct/overseerr/issues/591)) ([492e19d](https://github.com/sct/overseerr/commit/492e19df4014e67dc6a2de5903a33c25e13fcf45))
+- **notifications:** add notification for declined requests ([2f97f61](https://github.com/sct/overseerr/commit/2f97f61a6e8846975774aa16950a39ada2b1a016)), closes [#663](https://github.com/sct/overseerr/issues/663)
+- **notifications:** Webhook Notifications ([#632](https://github.com/sct/overseerr/issues/632)) ([a7cc7c5](https://github.com/sct/overseerr/commit/a7cc7c59753dd9649b2ec37eb9d46fe4fa8e1e1c))
+- **requests:** Request Overrides & Request Editing ([#653](https://github.com/sct/overseerr/issues/653)) ([bdb3372](https://github.com/sct/overseerr/commit/bdb33722e6df09dd6d8caa36b104b61c6b8dc00d))
+- **server:** add CONFIG_DIRECTORY env var to control config directory location ([fa8f112](https://github.com/sct/overseerr/commit/fa8f112c31ccb5ee6244f776bc97e76d81958539))
+- 4K Requests ([#559](https://github.com/sct/overseerr/issues/559)) ([6b2df24](https://github.com/sct/overseerr/commit/6b2df24a2e8f96dd2277a814d7e02015d1f80cdc))
+- map AniDB IDs from Hama agent to tvdb/tmdb/imdb IDs ([#538](https://github.com/sct/overseerr/issues/538)) ([0600ac7](https://github.com/sct/overseerr/commit/0600ac7c3a1bc0cdd906634d5f77ea3e99b10e94)), closes [#453](https://github.com/sct/overseerr/issues/453)
+
+### Reverts
+
+- **deps:** revert back to next@10.0.3 until sharp optional dependency bug is fixed ([7962964](https://github.com/sct/overseerr/commit/79629645aacc1a042919834da79bff0c1f69c9d6))
+
+# [1.16.0](https://github.com/sct/overseerr/compare/v1.15.0...v1.16.0) (2021-01-07)
+
+### Bug Fixes
+
+- **frontend:** adjust titlecard badge styling ([effc809](https://github.com/sct/overseerr/commit/effc80977a4ed732092254248f82363e52233171))
+- **frontend:** apply same titlecard hover effect to personcard ([67f2b57](https://github.com/sct/overseerr/commit/67f2b57f00216ded3b34965629d6fdd2f16bc25f))
+- **frontend:** only animate titlecard when showDetail is true ([0ab4c3c](https://github.com/sct/overseerr/commit/0ab4c3c36fe2c1ded142b6931111516f7f990a41))
+- **frontend:** use hardware acceleration for titlecard scale ([88810bf](https://github.com/sct/overseerr/commit/88810bf0a4ef74299f6541b60fa91cea3610f99c))
+- **plex-sync:** do not run plex sync if no admin exists ([493d82b](https://github.com/sct/overseerr/commit/493d82b6b066d77609cf66e005fd1f1472b8e011))
+
+### Features
+
+- **lang:** translations update from Weblate ([#495](https://github.com/sct/overseerr/issues/495)) ([b04eda6](https://github.com/sct/overseerr/commit/b04eda6c8a3bfcaa2a14b8a29612fdf690c9fba0))
+- **lang:** Translations update from Weblate ([#580](https://github.com/sct/overseerr/issues/580)) ([2bfe0f2](https://github.com/sct/overseerr/commit/2bfe0f2bf66956763ab26d5c54f26e6c456f59f7))
+- **notifications:** add pushover integration ([#574](https://github.com/sct/overseerr/issues/574)) ([ee5d018](https://github.com/sct/overseerr/commit/ee5d0181fc9a673b27aefd1d09b0a78c3d2e4f55))
+
+# [1.15.0](https://github.com/sct/overseerr/compare/v1.14.1...v1.15.0) (2021-01-04)
+
+### Bug Fixes
+
+- **api:** return 202 when same seasons are requested again ([5c84702](https://github.com/sct/overseerr/commit/5c847026aad79fcac4d020786ded9f867696c226))
+- **build:** fixes build to include commit tag for app build step ([289864a](https://github.com/sct/overseerr/commit/289864af1a995ce04834bf8a220cc238e1954d19))
+- **docs:** fix typo in build instructions ([#503](https://github.com/sct/overseerr/issues/503)) ([2b27a71](https://github.com/sct/overseerr/commit/2b27a715b07c27200ba1e5e9623629a34389276d))
+- **frontend:** add i18n for request text on titlecard ([a524b9c](https://github.com/sct/overseerr/commit/a524b9c4c8968f6823d33eb270dc26069fe4a725))
+- **frontend:** add localized strings for status checker ([2dcda39](https://github.com/sct/overseerr/commit/2dcda39d40d820419e098bd6f1101eb820e5b42d))
+- **frontend:** center text in movie auto-approve modal on small screens ([#510](https://github.com/sct/overseerr/issues/510)) ([1438b08](https://github.com/sct/overseerr/commit/1438b08cf0b358d79c6688c64be99f1718ec2d23)), closes [#507](https://github.com/sct/overseerr/issues/507)
+- **frontend:** change titlecard to only have a request button ([b5a3a7a](https://github.com/sct/overseerr/commit/b5a3a7a89fcaf86dd794dc419711677b53646577))
+- **frontend:** combine duplicate credits on a persons detail page ([d188f6f](https://github.com/sct/overseerr/commit/d188f6ffadff1564c47d5f33138e35498bed29fd)), closes [#504](https://github.com/sct/overseerr/issues/504)
+- **frontend:** disable pointer-events on titlecard badges ([ce06879](https://github.com/sct/overseerr/commit/ce0687922a94588b3492e8ddf2e84f54dd1a0d4e))
+- **frontend:** fix count of requests in request list ([f124d73](https://github.com/sct/overseerr/commit/f124d732a2911abdccb5abc11471efe61cc20f7a))
+- **frontend:** fix sliders overflowing on firefox ([67ac9e0](https://github.com/sct/overseerr/commit/67ac9e075f0ca1cfe7e4766d9168815d7ab600fa)), closes [#566](https://github.com/sct/overseerr/issues/566)
+- **frontend:** full season request modal fits on a smaller mobile UI ([#535](https://github.com/sct/overseerr/issues/535)) ([12db7a0](https://github.com/sct/overseerr/commit/12db7a065ad566b47d46de4b949343290894f153))
+- **frontend:** handle currentLibrary possibly being null on first manual sync ([93b57a7](https://github.com/sct/overseerr/commit/93b57a76f10a823615ca11ff59f523b67aa30fad))
+- **frontend:** increase titlecard status badge size on larger screens ([ba106c4](https://github.com/sct/overseerr/commit/ba106c447d76db2f9ac70a60c5b38cc60ab554fe))
+- **frontend:** search clear button now correctly triggers routing ([343f466](https://github.com/sct/overseerr/commit/343f466788abc308b91a414ef61bba816ac8875c))
+- **frontend:** set locale cookie expiration to be much longer ([fae4818](https://github.com/sct/overseerr/commit/fae481895736eab81d52eb93788beb00669fb355))
+- **frontend:** show movie/series badges always ([8cbf39a](https://github.com/sct/overseerr/commit/8cbf39a9d12eaee7720fa4721c350c1ef9dee856))
+- **frontend:** update login/setup images ([058fb65](https://github.com/sct/overseerr/commit/058fb65495baa08a0bd4c9e0aef320c6fc7d017b))
+- **holiday:** remove special holiday slider ([8c09033](https://github.com/sct/overseerr/commit/8c0903393cf2cb2a929ba70a8ab6ddcc4cba0574))
+- correctly deal with tmdb id duplicates between movies/series ([721ed9a](https://github.com/sct/overseerr/commit/721ed9a93087a57ae749388bddcacf26022e3df6)), closes [#526](https://github.com/sct/overseerr/issues/526)
+- use new commit tag file for app version as well ([d00e470](https://github.com/sct/overseerr/commit/d00e470b55327489b49d770144b7cfdb24045be6))
+
+### Features
+
+- **email:** add sendername to email notification ([#506](https://github.com/sct/overseerr/issues/506)) ([0185bb1](https://github.com/sct/overseerr/commit/0185bb1a7084c1faeb61fb1c63e34e26732711c8))
+- **frontend:** add clear-field-icon to search field ([#498](https://github.com/sct/overseerr/issues/498)) ([7434a26](https://github.com/sct/overseerr/commit/7434a26f76b5e9f74918f3e1a34443d20ecfcbe4))
+- **frontend:** add documentation link to about page ([c034496](https://github.com/sct/overseerr/commit/c034496f557a031aed35cd28dc7221d8cdf36643))
+- **frontend:** add telegram integration ([#491](https://github.com/sct/overseerr/issues/491)) ([c8d4d67](https://github.com/sct/overseerr/commit/c8d4d674f412082ad9e9da09abd79660365cf728))
+- **frontend:** filter/sorting for request list ([5add44c](https://github.com/sct/overseerr/commit/5add44cfb0379aa6fed7c3b867230292feacc684)), closes [#431](https://github.com/sct/overseerr/issues/431)
+- **notifications:** control notifcation types per agent ([8af6a1f](https://github.com/sct/overseerr/commit/8af6a1f566769c583af7dd9e18d162717835b7cc)), closes [#513](https://github.com/sct/overseerr/issues/513)
+- status checker to prompt users to reload their frontend when app version changes ([75a4264](https://github.com/sct/overseerr/commit/75a426437a4182e21da13684066966dd5bf8fc5e))
+
+## [1.14.1](https://github.com/sct/overseerr/compare/v1.14.0...v1.14.1) (2021-01-02)
+
+### Bug Fixes
+
+- **holiday:** remove special holiday slider ([22f2037](https://github.com/sct/overseerr/commit/22f2037ea6c5a0ba2ffa4d69f2b7cf42bdcf8575))
+
+# [1.14.0](https://github.com/sct/overseerr/compare/v1.13.0...v1.14.0) (2020-12-25)
+
+### Bug Fixes
+
+- **frontend:** add margin to ButtonWithDropdown component on movie/tv details page ([06fc98b](https://github.com/sct/overseerr/commit/06fc98b6b958221fa180f57f702c348f15b31f1c))
+- **frontend:** correctly position title card hover section ([#486](https://github.com/sct/overseerr/issues/486)) ([4b7af86](https://github.com/sct/overseerr/commit/4b7af86111a0300e1a137f23fa4ad1639fa55feb))
+- **frontend:** fix missing styles for alert component ([de3d288](https://github.com/sct/overseerr/commit/de3d288949b60d3a3af889d69a62bea2bc799ed7))
+- **frontend:** fix mobile dropdown in notifications settings ([6353cda](https://github.com/sct/overseerr/commit/6353cda5825f442dd539886c7b9ba437edf27ac4))
+- **frontend:** fix scaling titlecard content position ([bd94740](https://github.com/sct/overseerr/commit/bd947409e6e8ff313011b77adc76ccd5f9112c78))
+- **frontend:** improve flex header on movie/tv details page ([d7b1c28](https://github.com/sct/overseerr/commit/d7b1c2840690c144ebf29a360defcbd6fdb21354))
+- **frontend:** invalid dom-nesting title card fix ([#482](https://github.com/sct/overseerr/issues/482)) ([f2ebba7](https://github.com/sct/overseerr/commit/f2ebba7b1df775d33d2af6abc3ee2c9de5f2e57a)), closes [#476](https://github.com/sct/overseerr/issues/476)
+- **frontend:** remove vote permission for now ([5d06a34](https://github.com/sct/overseerr/commit/5d06a347311bd10c05d8f58068ca7104e265dcca))
+- **frontend:** sort person detail credits by tmdb votes ([17518db](https://github.com/sct/overseerr/commit/17518dbe7f545100770a892d03d1f8508adc3650))
+- **frontend:** status badge Unavailable renamed to Requested ([ed94a0f](https://github.com/sct/overseerr/commit/ed94a0f335c59de526dd812aea7616313fe002fd)), closes [#374](https://github.com/sct/overseerr/issues/374)
+- **frontend:** update titlecard status badge to new requested colors ([8f292d5](https://github.com/sct/overseerr/commit/8f292d538b937ea133175089979ef02599f6fef4))
+- **logs:** rotate logs on a daily basis instead of incrementing log filename ([395cbb2](https://github.com/sct/overseerr/commit/395cbb2be6c62f1d7573593e49a93615eaf22853))
+- improve apple-touch-icon and android app icons ([329a814](https://github.com/sct/overseerr/commit/329a814a8fb791122266c0b04b05848c71d68ba1))
+
+### Features
+
+- **lang:** translations update from Weblate ([#479](https://github.com/sct/overseerr/issues/479)) ([c8c74b0](https://github.com/sct/overseerr/commit/c8c74b0ae54fcc524aa8b2edf5a5c5e5db6c1638))
+- **notifications:** add slack notification agent ([1163e81](https://github.com/sct/overseerr/commit/1163e81adc7da1e8334155ebee5b4672a22143db)), closes [#365](https://github.com/sct/overseerr/issues/365)
+- add collections ([#484](https://github.com/sct/overseerr/issues/484)) ([a333a09](https://github.com/sct/overseerr/commit/a333a095820ce3f10857026ba4770a2fffeed7cb)), closes [#418](https://github.com/sct/overseerr/issues/418)
+- add separate auto approve permissions for Movies/Series ([4809257](https://github.com/sct/overseerr/commit/480925781691de456abc427fbbba161be11a3a8a)), closes [#268](https://github.com/sct/overseerr/issues/268)
+- simple failed request handling ([#474](https://github.com/sct/overseerr/issues/474)) ([02969d5](https://github.com/sct/overseerr/commit/02969d5426245062a2f53475d83c4a8639632c9d))
+- YouTube Movie/TV Trailers ([#454](https://github.com/sct/overseerr/issues/454)) ([e88dc83](https://github.com/sct/overseerr/commit/e88dc83aeba0475e3ad421d5ab130cea4fc9a806))
+
+# [1.13.0](https://github.com/sct/overseerr/compare/v1.12.1...v1.13.0) (2020-12-23)
+
+### Bug Fixes
+
+- **api:** correctly return firstAirDate for series in search endpoints ([32b4c99](https://github.com/sct/overseerr/commit/32b4c99950659d9e1da2ffa93c22383c54d0d904)), closes [#462](https://github.com/sct/overseerr/issues/462)
+- **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677))
+- **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa))
+- **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9))
+- **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff))
+- **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea))
+- clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e))
+- **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335))
+
+### Features
+
+- **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c))
+- **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5))
+- **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2))
+- **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8))
+- **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438)
+- **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437)
+- **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453)
+
+## [1.12.1](https://github.com/sct/overseerr/compare/v1.12.0...v1.12.1) (2020-12-22)
+
+### Bug Fixes
+
+- **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229))
+
+# [1.12.0](https://github.com/sct/overseerr/compare/v1.11.0...v1.12.0) (2020-12-22)
+
+### Bug Fixes
+
+- **api:** fix cross-imported type crashing build ([f35dae5](https://github.com/sct/overseerr/commit/f35dae56a583a5545375318fa5be994ae1f2557f))
+- **api:** prevent checking first admin account for plex server access ([22006e9](https://github.com/sct/overseerr/commit/22006e9dbde82609440f89bde9a40887b4742682))
+- **frontend:** add name, short_name and start_url to manifest ([#424](https://github.com/sct/overseerr/issues/424)) ([c6836e0](https://github.com/sct/overseerr/commit/c6836e02c810e8adb12c3a4b110f9604cf5b7b81))
+- **frontend:** adjust person card layout to deal with overflowing content ([4891298](https://github.com/sct/overseerr/commit/48912988915ae40606a900a6f1dd23fc25ed567f)), closes [#416](https://github.com/sct/overseerr/issues/416)
+- **frontend:** allow more special characters in search input ([5deb64a](https://github.com/sct/overseerr/commit/5deb64a87fd70e97da27a025ad11fb8ace0e0b57)), closes [#430](https://github.com/sct/overseerr/issues/430)
+- **logs:** improve logging when adding to sonarr/radarr ([4b50522](https://github.com/sct/overseerr/commit/4b505223b881a750007e3fbc7d4bcb9677d4d412))
+- only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29))
+- **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e))
+- **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363)
+
+### Features
+
+- **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33))
+- **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123))
+- default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388)
+- import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281)
+- **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406)
+- **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309)
+
+### Reverts
+
+- **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac))
+
+# [1.11.0](https://github.com/sct/overseerr/compare/v1.10.0...v1.11.0) (2020-12-20)
+
+### Features
+
+- **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2))
+- **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a))
+- **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303)
+- **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574))
+- **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b))
+- **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72))
+- **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161))
+
+# [1.10.0](https://github.com/sct/overseerr/compare/v1.9.1...v1.10.0) (2020-12-19)
+
+### Bug Fixes
+
+- **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
+- **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
+- **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
+- **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
+- **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
+
+### Features
+
+- **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
+- add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
+- **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
+- **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
+
+## [1.9.1](https://github.com/sct/overseerr/compare/v1.9.0...v1.9.1) (2020-12-18)
+
+### Bug Fixes
+
+- change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e))
+
+# [1.9.0](https://github.com/sct/overseerr/compare/v1.8.0...v1.9.0) (2020-12-18)
+
+### Features
+
+- api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
+- **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
+- **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
+- **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
+- **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
+- allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
+- anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
+
+# [1.8.0](https://github.com/sct/overseerr/compare/v1.7.0...v1.8.0) (2020-12-17)
+
+### Features
+
+- **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
+
+# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17)
+
+### Bug Fixes
+
+- **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
+- **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
+- **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
+- **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
+- **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
+- **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
+- **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
+- **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
+
+### Features
+
+- **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
+
+# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16)
+
+### Bug Fixes
+
+- **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
+- **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
+- **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
+- **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
+- **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
+- **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
+- **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
+- spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
+
+### Features
+
+- add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
+- **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
+- **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
+- **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
+
+# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15)
+
+### Bug Fixes
+
+- **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
+- **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
+- **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
+- add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
+- **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
+
+### Features
+
+- **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
+- about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
+
+# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15)
+
+### Bug Fixes
+
+- changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
+- **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
+
+### Features
+
+- **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
+
+## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14)
+
+### Bug Fixes
+
+- **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
+- **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
+- **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
+- **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
+- **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
+- **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
+
+## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14)
+
+### Bug Fixes
+
+- **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
+- **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
+- **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
+
+# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14)
+
+### Bug Fixes
+
+- **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737))
+
+### Features
+
+- **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998))
+- **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7))
+- **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0))
+- **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2))
+- **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b))
+- **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e))
+- **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814))
+- **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39))
+- **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2))
+- **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33))
+
+# [1.2.0](https://github.com/sct/overseerr/compare/v1.1.0...v1.2.0) (2020-12-11)
+
+### Bug Fixes
+
+- **frontend:** person cards now show correctly in ListView's ([ccb9855](https://github.com/sct/overseerr/commit/ccb98553f104c1aebd33796b7090cc9bbe964bd7))
+- **frontend:** properly remove site overlay when closing modals ([3fa7ff9](https://github.com/sct/overseerr/commit/3fa7ff9858d14d132151f3329164d55d74638f53))
+- **frontend:** switch to using Transition component for modals ([b16fbaf](https://github.com/sct/overseerr/commit/b16fbafa1f3d5e105c0a4ba6f1d66aa064019636)), closes [#220](https://github.com/sct/overseerr/issues/220)
+- fix missing personid in Discover ([d8060af](https://github.com/sct/overseerr/commit/d8060afe02574337f51b88cab0a0f824976ac721))
+- missing personId in ListView component ([6502feb](https://github.com/sct/overseerr/commit/6502feb1a5be3c6daab33230814fe74632c87f7e))
+- **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216)
+- **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609))
+
+### Features
+
+- **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986))
+- person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3))
+- **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f))
+- **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e))
+- **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1))
+- **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e))
+- **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728))
+- **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367))
+- **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737))
+- **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813))
+- **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344))
+
+# [1.1.0](https://github.com/sct/overseerr/compare/v1.0.0...v1.1.0) (2020-12-08)
+
+### Bug Fixes
+
+- fix a few misc unused imports and useless assignments/conditionals ([8e6daf7](https://github.com/sct/overseerr/commit/8e6daf7bd271ce5bebf4a00f5bb1144bd6b60aa5))
+- **frontend:** dont show delete button in request list for users without correct permission ([83fde46](https://github.com/sct/overseerr/commit/83fde46a59c6f1910806a6106b5526b8adbc386c))
+- **frontend:** push updated i18n locale files ([b4002d7](https://github.com/sct/overseerr/commit/b4002d71323a04e7991198cedc263660e872df8d))
+
+### Features
+
+- generate real api key ([a839370](https://github.com/sct/overseerr/commit/a8393707fec85a9262af5ba8c03d205190b2235b))
+- **frontend:** add i18n strings for request list and request item ([6c4022f](https://github.com/sct/overseerr/commit/6c4022fb236583ad20d4c4c6693c1339e165b4af))
+- **frontend:** initial version of the requests page (no filtering/sorting) ([1ba027b](https://github.com/sct/overseerr/commit/1ba027b4357e078c3f177d9d07208049f0c1ce65))
+- **frontend:** only load request/tmdb cards when in the browser view ([2d51efd](https://github.com/sct/overseerr/commit/2d51efd71612ec969b83c62d6aa0dac6df9391a3))
+
+# 1.0.0 (2020-12-06)
+
+### Bug Fixes
+
+- **api:** fix scheduling for plex full sync (maybe) ([7287a6a](https://github.com/sct/overseerr/commit/7287a6a95703b23acc0c4f6eb3beb9ec2295e33f))
+- **frontend:** always show request modal option for tv ([2b46268](https://github.com/sct/overseerr/commit/2b462688243531b4be620a942f59defd4e0534d0))
+- **frontend:** canceled movie request should set parent movie status back to unknown ([#198](https://github.com/sct/overseerr/issues/198)) ([139871f](https://github.com/sct/overseerr/commit/139871f218812a15f742aa66408db12704e0b9b5))
+- **frontend:** close request modals when complete ([85ae499](https://github.com/sct/overseerr/commit/85ae4998f0ba8d4869b9b244f2c440b9df1310d2))
+- **frontend:** dont show runtime if there is no runtime data ([e0c39ae](https://github.com/sct/overseerr/commit/e0c39aeca119b822f2a54ff05a97f91780ddd052))
+- **frontend:** fix missing data for request modal title i18n ([a56fd16](https://github.com/sct/overseerr/commit/a56fd16ab6638d4649fe9f8b9d75e7cae7742f73))
+- **frontend:** fix missing import for ReactNode type in Slider ([b26a234](https://github.com/sct/overseerr/commit/b26a2347e7b0f7ff8720a204d9faefd501ba886c))
+- **frontend:** fix modal design and rename some text for adding servers ([46d99b0](https://github.com/sct/overseerr/commit/46d99b02b1c992c7b8dde2150217ed9ce326b7a5))
+- **frontend:** fix opening popups on safari ([364d9d1](https://github.com/sct/overseerr/commit/364d9d105ca3690fcd5f635485d7c025353bb9f1))
+- **frontend:** fix request card placeholder sizes for mobile ([ef62c67](https://github.com/sct/overseerr/commit/ef62c67480ed52d753ea6db8205f035b2e9da272))
+- **frontend:** show a badge on requestcard for partially available status ([59056c4](https://github.com/sct/overseerr/commit/59056c44f942a37df536ff947b5faccc27f32246))
+- dont cross import SyncStatus type ([e032e38](https://github.com/sct/overseerr/commit/e032e385a5253d215490255c676f42ee48f39428))
+- fix type import from server side crashing build process ([89be56d](https://github.com/sct/overseerr/commit/89be56d8403ebc60c411e7cb357593edd9c79bb2))
+- **frontend:** fix title detail background image to be centered ([b92f64f](https://github.com/sct/overseerr/commit/b92f64fa6e167bc89168d8f5c0f2eb12efa0b6f0))
+- **frontend:** fixed similar/recommendations showing when empty ([#180](https://github.com/sct/overseerr/issues/180)) ([a3ca9b4](https://github.com/sct/overseerr/commit/a3ca9b40c552e6cc5effc2f57f7562ff6f723e42))
+- **frontend:** have tvDetail use the new RequestModal ([6aca826](https://github.com/sct/overseerr/commit/6aca82607b97d4a4ad74e2ea843d52fba4689e6a))
+- **frontend:** reinitalize plex form after data loads ([97e3036](https://github.com/sct/overseerr/commit/97e30367fb5d2d27efc42c1d76b0d051b6f1da76))
+- **frontend:** remove requestId from tilecard request modal component ([61b6152](https://github.com/sct/overseerr/commit/61b6152e8915c99585b944756a61d33b8c8a0307))
+- **frontend:** run initial props for children components after getting the user ([fdf9f38](https://github.com/sct/overseerr/commit/fdf9f38776b6d4c08b3505c03b354639cebb011f))
+- **frontend:** when there were no results in the list view, it would call fetch more infinitely ([c0ce87b](https://github.com/sct/overseerr/commit/c0ce87b6f65bf0ab1301c7ca61090d779709529f))
+- fixed an issue with eslint-prettier on windows ([#32](https://github.com/sct/overseerr/issues/32)) ([b673ea1](https://github.com/sct/overseerr/commit/b673ea1b18ca0f432996bb9e4e5d148af0247170))
+- fixes next.js build to not include server files ([de8ee9b](https://github.com/sct/overseerr/commit/de8ee9ba85e0160b0b472cab44f92c01796efec8))
+
+### Features
+
+- add migration for delete cascades on season requests/seasons ([c688cf6](https://github.com/sct/overseerr/commit/c688cf60c710f0cf0b2da5ba6b0c18a2d137e7f9))
+- **api:** email notification agent ([0962392](https://github.com/sct/overseerr/commit/0962392e3930c7fdcb3164b9143cc8faca38bdfa))
+- **frontend:** add french language file ([cd6d8a8](https://github.com/sct/overseerr/commit/cd6d8a8216e7ae183b046d26cd22f3c1dc1d2b35))
+- **frontend:** add translatable strings for request card ([0d2f360](https://github.com/sct/overseerr/commit/0d2f360c22cd9bb50ae04f00a25e5fcc6c21bcdd))
+- **frontend:** added more localized strings ([659a601](https://github.com/sct/overseerr/commit/659a6018777718f7a90141307678d8dadcfd77f8))
+- actually include email templates in built server files ([a28a8b3](https://github.com/sct/overseerr/commit/a28a8b37b0afc79583e4a7191a91f73ff6d3adad))
+- add application url config to main settings ui ([a359672](https://github.com/sct/overseerr/commit/a359672ebafffef742858814f0faa918e0341aa3))
+- add filtering for requests api ([cb9ae25](https://github.com/sct/overseerr/commit/cb9ae25d94f21e97113dfea3ca45c7002089e344))
+- add trending to discover page ([ff8b9d8](https://github.com/sct/overseerr/commit/ff8b9d8e7ed228a153c2da4d237f7a4f99a79321))
+- force setup if app is not initialized ([a99705f](https://github.com/sct/overseerr/commit/a99705f6a5674b436ae28cbc558f4ee6e99ac910))
+- initial user list (no edit/delete yet) and job schedules ([24a0423](https://github.com/sct/overseerr/commit/24a0423f3b14303cfb0e83aef6e9e3bb273c5ba9))
+- manage series slideover added (and approve/decline/delete hooked up) ([236c4e5](https://github.com/sct/overseerr/commit/236c4e5e6126d2424a4badc08b7f7e6d1d70f401))
+- media delete option in manage media slideover ([250f484](https://github.com/sct/overseerr/commit/250f48492c95d74e40d95d3f026d2952157bc6e1))
+- other email notifications for approved/available ([0d73d88](https://github.com/sct/overseerr/commit/0d73d88f35b03e993f305873dc72672003c7d9e5))
+- radarr edit/create modal/backend functionality ([c4ac357](https://github.com/sct/overseerr/commit/c4ac357ef4cdd7a2c610260db46a4f0c325cd785))
+- season creation migration ([978f92a](https://github.com/sct/overseerr/commit/978f92a1c589ac404a3cb1103a68a8a5ffb0dd7d))
+- sonarr edit/delete modal ([3204326](https://github.com/sct/overseerr/commit/320432657e6ccf4d255238098e03590f28267bdb))
+- throw 404 when movie/tv show doesnt exist ([0601b44](https://github.com/sct/overseerr/commit/0601b446873e2eaf042044dd6a995b713586b0cc))
+- **api:** sonarr api wrapper / send to sonarr ([9385592](https://github.com/sct/overseerr/commit/9385592362eeba1dba05c5aa8fc7a2de1d054d74))
+- **frontend:** add header styling to movie/tv recommendation and similar list views ([f5f2545](https://github.com/sct/overseerr/commit/f5f2545520a43daa23e1276d24ff60d794ebbc6e))
+- **frontend:** add links to detail pages from new request card ([6ad3384](https://github.com/sct/overseerr/commit/6ad3384a78f7bcb03f409cce8b35cc61d634d6b2))
+- **frontend:** new design for request card ([93738e1](https://github.com/sct/overseerr/commit/93738e154c41fd11d5c6cf3d35573daf54ead471))
+- **frontend:** update favicon ([886389a](https://github.com/sct/overseerr/commit/886389a361da54c616da3bdfeee9a85e9d12bcf3))
+- notification framework ([d8e542e](https://github.com/sct/overseerr/commit/d8e542e5fe2ed76dcb20fb6dfc5f59430cd4245d))
+- notifications for media_available and media_approved ([a6c5e65](https://github.com/sct/overseerr/commit/a6c5e65bbfc196545471e99fe2e5b7194f9dd387))
+- rotten tomatoes scores on movie/tv details pages ([1694f60](https://github.com/sct/overseerr/commit/1694f60e8aa475ceeb7f170a783ec0ba70bd4bce))
+- upcoming movies on discover ([67290dd](https://github.com/sct/overseerr/commit/67290dd502571a22dcf8559ac07f42e855275bd0))
+- upcoming/trending list views and larger title cards ([94eaaf9](https://github.com/sct/overseerr/commit/94eaaf96b4302a832c52ccb72009b3593452c779))
+- upgrade tailwindcss to 2.0.1 ([fb5c791](https://github.com/sct/overseerr/commit/fb5c791b0b6b7593a472bf01713999a001f92dc7))
+- user edit functionality (managing permissions) ([185ac26](https://github.com/sct/overseerr/commit/185ac2648fd21c4bf9692ac5ac055e9c740065ca))
+- **api:** plex tv sync and recently added sync ([1390cc1](https://github.com/sct/overseerr/commit/1390cc1f130bb3975996e84b12ac833f55f2f753))
+- **frontend:** allow permission check for showing nav items ([0b239f0](https://github.com/sct/overseerr/commit/0b239f0bdfb1394897bce5c50b0d112abfbb4ad7))
+- **frontend:** alpha notice ([33da7e9](https://github.com/sct/overseerr/commit/33da7e9df3a2546b0f208bd3b1d1f268e343cead))
+- **frontend:** buttonWithDropdown component added (no hookups yet) ([4975841](https://github.com/sct/overseerr/commit/4975841b5d4ba4ed1ba8cacaa5a063eeb3b8c311))
+- **frontend:** cancel movie request modal ([1f9cbbf](https://github.com/sct/overseerr/commit/1f9cbbfdf1ac98e54de5b8777c52c7bfc69c7e20))
+- **frontend:** improved settings menu design for mobile ([16221a4](https://github.com/sct/overseerr/commit/16221a46a7d57c77f53aa0186263aa27267d9863))
+- **frontend:** initial Settings design ([8742da0](https://github.com/sct/overseerr/commit/8742da0ebb92d2f78309a998de0f67e788e14376))
+- **frontend:** plex library scan ([1bc3f7b](https://github.com/sct/overseerr/commit/1bc3f7be4b07211563a1e254c28ce51e1bc337a2))
+- **frontend:** plex settings page ([47714b6](https://github.com/sct/overseerr/commit/47714b698cf4351c1ee38bdf0b672d9f0baed03a))
+- **frontend:** radarr delete modal ([877a518](https://github.com/sct/overseerr/commit/877a5184158fb4aa371fa2ea2107032543c9aa37))
+- **frontend:** recently added on discover ([06dc606](https://github.com/sct/overseerr/commit/06dc606bcfeb50b7be1c35ac180c10738bade458))
+- **frontend:** slideover initial work ([14b9cb6](https://github.com/sct/overseerr/commit/14b9cb610c0dcfef939ebec328f371e1cdfb689d))
+- tv request modal status hookup ([5f8114f](https://github.com/sct/overseerr/commit/5f8114f730b067eb710704952824057e7b5b8fbf))
+- **.editorconfig:** add .editorconfig ([b982066](https://github.com/sct/overseerr/commit/b982066327525156f8dd0d32818d3fe7cb28f9c8))
+- **api:** add external ids to movie/tv response ([4aa7431](https://github.com/sct/overseerr/commit/4aa74319e0adcc19041239e57a00bc40fb127826))
+- **api:** add movie details endpoint ([b176148](https://github.com/sct/overseerr/commit/b1761484cb2861329763d51a868f37dd3098760d))
+- **api:** add tmdb discover api wrapper ([#67](https://github.com/sct/overseerr/issues/67)) ([839448f](https://github.com/sct/overseerr/commit/839448fcc8cc14ea83092af82e2ba3d0d92c9b73))
+- **api:** allow plex logins from users who have access to the server ([5147140](https://github.com/sct/overseerr/commit/514714071dfe4be04e607fe6412f5b3f0ef74dd4))
+- **api:** decouple media requests from media info ([8577db1](https://github.com/sct/overseerr/commit/8577db1be16f099d92c6649bbfb15f15e09a2f73))
+- **api:** discover endpoint for movie/tv ([#73](https://github.com/sct/overseerr/issues/73)) ([258bb93](https://github.com/sct/overseerr/commit/258bb93be2acc2ca32eaaefb617a5c326c5943ba))
+- **api:** initial implementation of the auth system ([#30](https://github.com/sct/overseerr/issues/30)) ([5343f35](https://github.com/sct/overseerr/commit/5343f35e5b572fe366a8712b24bd735de30e6170))
+- **api:** plex Sync (Movies) ([1be8b18](https://github.com/sct/overseerr/commit/1be8b183617c3a44ab8d4454a64b43dfe1d877fe))
+- **api:** public settings route ([#57](https://github.com/sct/overseerr/issues/57)) ([c0166e7](https://github.com/sct/overseerr/commit/c0166e7ecb5df110a4167f33338ed6406bf47f41))
+- **api:** radarr api wrapper / send to radarr when requests approved ([#93](https://github.com/sct/overseerr/issues/93)) ([48d62c3](https://github.com/sct/overseerr/commit/48d62c3178488d0d51831155ddd35cc31867db2b))
+- **api:** request api ([#80](https://github.com/sct/overseerr/issues/80)) ([f4c2c47](https://github.com/sct/overseerr/commit/f4c2c47e569e7faea7f99664966cb98b321ce952))
+- **api:** tmdb api wrapper / multi search route ([#62](https://github.com/sct/overseerr/issues/62)) ([c702c17](https://github.com/sct/overseerr/commit/c702c17cee00a52b23f685206e2d5d0c2eddf5a2))
+- **api:** tmdb trending api wrapper ([#68](https://github.com/sct/overseerr/issues/68)) ([ba34e54](https://github.com/sct/overseerr/commit/ba34e54d77d142d211df58d6ce9f53b6e673e004))
+- **api:** tv details endpoint ([a3beeed](https://github.com/sct/overseerr/commit/a3beeede7e72e99c7595673a27e38611ca4bb0cd))
+- **api:** validate plex when settings are saved ([8f6247d](https://github.com/sct/overseerr/commit/8f6247d82160704a3cfb76262696957b27641e87))
+- **api-user:** add basic User Entity and basic routing to fetch all users ([d902ef7](https://github.com/sct/overseerr/commit/d902ef72770712f2f71f33c09bca9ba99a30fc64))
+- **components/plexloginbutton:** added PlexLoginButton ([0abf743](https://github.com/sct/overseerr/commit/0abf743b17c664b58da18bdbf176f4a55ddc4179))
+- **extensions.json:** added recommended extensions for VSCode ([5dc9b51](https://github.com/sct/overseerr/commit/5dc9b510b8049516ad889c9d76a2f84daa0d2718))
+- **frontend:** add cancel request modal for titlecards ([f22f8c5](https://github.com/sct/overseerr/commit/f22f8c5d734be5cc0b1dcca869458a7321cd43a2))
+- **frontend:** approve/decline request well added to movie detail ([8f21358](https://github.com/sct/overseerr/commit/8f21358f797ed55923d90ba43acf1126856e9dfd))
+- **frontend:** basic discover page (only movies) ([#74](https://github.com/sct/overseerr/issues/74)) ([bbfe349](https://github.com/sct/overseerr/commit/bbfe349b52d308620796b37aaf986a0ed1ff0006))
+- **frontend:** design updates for responsive titlecards ([31809d9](https://github.com/sct/overseerr/commit/31809d952c8bafde3f63e2c1d952cc013149940e))
+- **frontend:** discover tv/movies full page ([be0003a](https://github.com/sct/overseerr/commit/be0003a85dc4e91799e85019aeb1110bd524a026))
+- **frontend:** initial search functionality ([#78](https://github.com/sct/overseerr/issues/78)) ([342d1a3](https://github.com/sct/overseerr/commit/342d1a3c75b32b172a51ca7d82fdfde8510abedf))
+- **frontend:** loading spinner ([de84658](https://github.com/sct/overseerr/commit/de84658b48985e24b0f92a1690387f6d59d0bc16))
+- **frontend:** logo updates ([5a43ec5](https://github.com/sct/overseerr/commit/5a43ec5405855deb244e8085484a9d2b743caba6))
+- **frontend:** modal component and basic request hookup ([#91](https://github.com/sct/overseerr/issues/91)) ([626099a](https://github.com/sct/overseerr/commit/626099a2c98fb30d0cb53d8ccf79a6bf75a00059))
+- **frontend:** new dashboard concept ([#82](https://github.com/sct/overseerr/issues/82)) ([eae38bb](https://github.com/sct/overseerr/commit/eae38bb9ec8588856f319387d2f262d7ee3f7e9c))
+- **frontend:** refresh indicator for titlecards / toasts ([4638fae](https://github.com/sct/overseerr/commit/4638fae336edc62a539796b3f55277a238683603))
+- **frontend:** request card / recent requests ([371e433](https://github.com/sct/overseerr/commit/371e43356d2c057e52368c32ffe2af1744311d91))
+- **frontend:** title detail (movie) initial version ([73ce24a](https://github.com/sct/overseerr/commit/73ce24a37bda3713e8cedc44e1ed065bdbc4ee4f))
+- **frontend/api:** beginning of new request modal ([2bf7e10](https://github.com/sct/overseerr/commit/2bf7e10e32718b36799be2feb0a7f9ff54d85744))
+- **frontend/api:** cast included with movie request and cast list on detail page ([04252f8](https://github.com/sct/overseerr/commit/04252f88bbdf51949923586feda582f86ac668ce))
+- **frontend/api:** i18n support ([9131254](https://github.com/sct/overseerr/commit/9131254f3371f12a17de44b6fa8f9bfb0e5c002e))
+- **frontend/api:** movie recommendations/similar request and frontend detail page update ([6398e36](https://github.com/sct/overseerr/commit/6398e3645a1e4ddbb9de9f4fda0a0659b4cac4d0))
+- **frontend/api:** tv details page ([02cbb5b](https://github.com/sct/overseerr/commit/02cbb5b030a3af5d62ab6c4cafdd4d800b4f61f4))
+- **frontend/api:** tv request modal (no status. only request) ([608b966](https://github.com/sct/overseerr/commit/608b96600a926adf16331b36e77789afa5d67069))
+- logout route/sign out button ([#54](https://github.com/sct/overseerr/issues/54)) ([cb9098f](https://github.com/sct/overseerr/commit/cb9098f457f79b71734959fd924b6c72ca77d61d))
+- user avatars from plex ([#53](https://github.com/sct/overseerr/issues/53)) ([e6349c1](https://github.com/sct/overseerr/commit/e6349c13a0eb0489289aa7663fcc64fa7d2906e6))
+- **layout:** created Layout component ([1f497e8](https://github.com/sct/overseerr/commit/1f497e8913146ceb9748d667e638141b2ca4612a))
+- **login component/route:** add: Login Component and Route ([6e47be2](https://github.com/sct/overseerr/commit/6e47be2fa865bcd51582ce30ebee6fd820c5f9dd))
+- **login route conditional:** on login route, do not display layout ([7d179ae](https://github.com/sct/overseerr/commit/7d179ae3b42d8ffae5e1b6e266038793260f1bbe))
+- **pass pageprops to loginpage:** pass page props to loginPage ([1597188](https://github.com/sct/overseerr/commit/159718891fb363001c650ac8b7e1446a1520ce4a))
+- **plex/utils:** added Plex OAuth class ([72f9624](https://github.com/sct/overseerr/commit/72f9624f1db721fe0324b7be9f0f811d2ae02389))
+- bootstrap the basic app structure ([89a6017](https://github.com/sct/overseerr/commit/89a6017c7f6f7637fe249ac0d667a652f44e02bb))
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 609c9d727c..b343448d06 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,53 +6,50 @@ 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.
+> 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.
>
-> **We expect AI-assisted development, not AI-driven development.**
-> Use AI as a tool to help you write code. Do not let an AI agent
-> autonomously generate an entire contribution and submit it on your behalf.
-> We have been increasingly receiving low-effort, fully AI-generated PRs
-> and will not tolerate them. Contributors who repeatedly submit unreviewed
-> AI output may result in a ban.
+> **We expect AI-assisted development, not AI-driven development.** Use AI as a tool to help you write code. Do not let an AI agent autonomously generate an entire contribution and submit it on your behalf. It does not matter who clicks submit, an agent acting on its own or a human relaying its output unread. What matters is whether you reviewed, tested, and understood the change before it went up. We have been increasingly receiving low-effort, fully AI-generated PRs and will not tolerate them. Contributors who repeatedly submit unreviewed AI output may be banned.
>
-> **Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban.** Signs of unreviewed AI output include but are not limited to:
+> **Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban if repeated even after several warnings.** Signs of unreviewed AI output include but are not limited to:
>
> - Blank or template-default PR descriptions
> - AI-generated PR descriptions that replace our template with their own structure (e.g., "Summary / What changed / Root cause / Test plan" instead of following the PR template; this is the default output format of tools like Claude Code and is an immediate indicator that the PR was not reviewed by a human)
+> - AI-generated content moved under our template's headers without being rewritten in your own words. Matching our structure is not the same as writing the description yourself
> - Unchecked checklists or missing checklist entirely
> - Failing CI checks that would have been caught by running `pnpm build`
> - Code that does not match the described changes
> - Inability to answer questions about the submitted code
>
> **Read and follow the [Contributing Guide](CONTRIBUTING.md) before submitting.**
-> If your AI tool generates its own PR description format, it is your
-> responsibility to rewrite it to follow our template before submitting.
-> An incomplete PR template tells maintainers that insufficient review has
-> been performed on the submission, regardless of the actual code quality.
-> We may close such PRs without review.
+> If your AI tool generates its own PR description format, it is your responsibility to rewrite it to follow our template before submitting, and rewrite means write the content yourself, not relabel the AI's output under our section headers. An incomplete PR template tells maintainers that insufficient review has been performed on the submission, regardless of the actual code quality. We may close such PRs without review.
>
> If you are using **any kind of AI assistance** to contribute to Seerr, it must be disclosed in the pull request.
### Disclosure Requirements
-If you are using any kind of AI assistance while contributing to Seerr,
-**this must be disclosed in the pull request description**, along with
-the extent to which AI assistance was used (e.g., docs only vs. code generation).
-If PR responses (comments, review replies) are being generated by AI,
-disclose that as well.
+If you are using any kind of AI assistance while contributing to Seerr, **this must be disclosed in the pull request description**, along with the extent to which AI assistance was used (e.g., docs only vs. code generation).
-As a small exception, trivial tab-completion doesn't need to be disclosed,
-so long as it is limited to single keywords or short phrases.
+PR responses (comments, review replies) must be written in your own words, per the Expectations below. The one exception is AI-assisted translation, if you use AI to translate a response into English, disclose that here too.
-Example disclosures:
+As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases.
-> **AI Disclosure:** This PR was written primarily by Claude Code.
-> **AI Disclosure:** I consulted ChatGPT to understand the codebase but the solution
+**Disclosure does not exempt a PR from the AI-driven development policy above.**
+If the implementation was primarily generated by an AI tool in one or two prompts, and your own involvement was limited to testing or a small edit, that is AI-driven development whether or not you disclosed it. Disclosure tells us how much scrutiny to apply to your PR. It does not turn an AI-driven contribution into an AI-assisted one.
+
+Example disclosures that meet our bar:
+
+> - **AI Disclosure:** I designed the approach and wrote the implementation myself.
+> I used Claude to scaffold the initial component and help debug a TypeScript
+> generics issue.
+> - **AI Disclosure:** I consulted ChatGPT to understand the codebase but the solution
> was fully authored manually by myself.
-> **AI Disclosure:** None.
+> - **AI Disclosure:** None.
+
+A disclosure like the one below is honest, but the PR will still be closed as
+AI-driven:
+
+> **AI Disclosure:** This PR was written primarily by Claude Code, then tested
+> and lightly tweaked.
When using AI assistance, we expect contributors to:
@@ -62,51 +59,29 @@ When using AI assistance, we expect contributors to:
filling out the PR template, testing section, and checklist.
- **Run the build and tests** before submitting.
-Failure to disclose AI assistance is first and foremost disrespectful to the
-human maintainers on the other end of the pull request, but it also makes it
-difficult to determine how much scrutiny to apply to the contribution.
+Failure to disclose AI assistance is first and foremost disrespectful to the human maintainers on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution.
-In a perfect world, AI assistance would produce equal or higher quality
-work than any human. That is not the world we live in today, and in most cases
-it is generating slop.
+In a perfect world, AI assistance would produce equal or higher quality work than any human. That is not the world we live in today, and in most cases it is generating slop.
-When using AI assistance, we expect contributors to understand the code
-that is produced and be able to answer critical questions about it. It
-is not a maintainer's job to review a PR so broken that it requires
-significant rework to be acceptable.
+When using AI assistance, we expect contributors to understand the code that is produced and be able to answer critical questions about it. It is not a maintainer's job to review a PR so broken that it requires significant rework to be acceptable.
Please be respectful to maintainers and disclose AI assistance.
### Expectations for AI-Assisted Contributions
-1. **PR descriptions and all comments must be your own words.** Do not paste
- LLM output as your PR description, review response, or issue comment.
- We want *your* understanding and explanation of the changes, not a
- machine-generated summary. An exception is made for LLM-assisted
- translation, however, note it explicitly if used.
-
-2. **Contributions must be concise and focused.** A PR that claims to fix one thing
- but touches a bunch of unrelated code will be rejected. This is a
- common side effect of broad AI prompts and makes review unnecessarily
- difficult.
-
-3. **You must be able to handle review feedback yourself.** If you cannot discuss or
- implement requested changes without round-tripping reviewer comments
- through an AI, that tells us you don't understand the code you
- submitted. We will close the PR.
-
-4. **Don't commit non-project files.** Editor configs, AI tool
- directories, and other local tooling files do not belong in the
- repository. Keep your commits clean.
-
-5. **Changes must be tested.** Build the project, run the tests, and
- manually verify the functionality you modified. Don't just assume
- CI will catch everything.
-
-6. **Final discretion lies with the reviewers.** If a PR cannot be
- reasonably reviewed for any reason due to over-complexity, size, or poor
- structure, it will be rejected. This applies equally to AI-assisted
- and non-AI-assisted contributions.
+1. **PR descriptions and all comments must be your own words.** Do not paste LLM output as your PR description, review response, or issue comment. We want *your* understanding and explanation of the changes, not a machine-generated summary. An exception is made for LLM-assisted translation, however, note it explicitly if used.
+
+ **Don't be a meat proxy** (the term is borrowed from [this post](https://gruhn.me/blog/2026-08-03/)). If a maintainer asks you a question and you paste it into an AI, then paste the answer back without reading it, checking it against the code, and putting it in your own words, you haven't added anything to the exchange. We could have asked the AI ourselves and skipped the wait. This project is maintained by volunteers reviewing your contribution on their own time, and relaying answers you haven't read wastes that time rather than saving it.
+
+2. **Contributions must be concise and focused.** A PR that claims to fix one thing but touches a bunch of unrelated code will be rejected. This is a common side effect of broad AI prompts and makes review unnecessarily difficult.
+
+3. **You must be able to handle review feedback yourself.** If you cannot discuss or implement requested changes without round-tripping reviewer comments through an AI, that tells us you don't understand the code you submitted. We will close the PR.
+
+4. **Don't commit non-project files.** Editor configs, AI tool directories, and other local tooling files do not belong in the repository. Keep your commits clean.
+
+5. **Changes must be tested.** Build the project, run the tests, and manually verify the functionality you modified. Don't just assume CI will catch everything.
+
+6. **Final discretion lies with the reviewers.** If a PR cannot be reasonably reviewed for any reason due to over-complexity, size, or poor structure, it will be rejected. This applies equally to AI-assisted and non-AI-assisted contributions.
## Development
diff --git a/Dockerfile b/Dockerfile
index cceace779e..4e7fa1110d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22.22.2-alpine3.23@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f AS base
+FROM node:22.23.2-alpine3.23@sha256:46825fbbd4e996a78b7a2cdc08d75e38a5a505bdab95dcda55605359bf124bc6 AS base
ARG SOURCE_DATE_EPOCH
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
@@ -50,7 +50,7 @@ RUN pnpm build
RUN rm -rf .next/cache
-FROM node:22.22.2-alpine3.23@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f
+FROM node:22.23.2-alpine3.23@sha256:46825fbbd4e996a78b7a2cdc08d75e38a5a505bdab95dcda55605359bf124bc6
ARG SOURCE_DATE_EPOCH
ARG COMMIT_TAG
ENV NODE_ENV=production
diff --git a/Dockerfile.local b/Dockerfile.local
index da7e5710d3..a39feb8a5f 100644
--- a/Dockerfile.local
+++ b/Dockerfile.local
@@ -1,4 +1,4 @@
-FROM node:22.22.2-alpine3.23@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f
+FROM node:22.23.2-alpine3.23@sha256:46825fbbd4e996a78b7a2cdc08d75e38a5a505bdab95dcda55605359bf124bc6
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
diff --git a/README.md b/README.md
index aafdc0224d..301020d17c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,31 @@
+
+> [!IMPORTANT]
+> A pull request to Seerr is on hold until Lidarr support is fully merged.
+
+> [!WARNING]
+>
+> ### Book Support Fork
+>
+> This fork adds book support to Seerr using [Hardcover](https://hardcover.app). It is currently in testing and under active development.
+>
+> **Before you begin, please make a backup of your database and settings.**
+>
+> #### Requirements
+>
+> - You must be running Seerr v3.1.0 or later. If you are coming from an older version, please update to v3.1.0 first.
+> - A [Hardcover](https://hardcover.app) account is required. You will need to generate a personal API key from your [account settings](https://hardcover.app/account/api) and enter it in the Seerr settings page.
+>
+> #### Migration
+>
+> - If you are already running Seerr v3.1.0, simply replace your installation with this fork. Your existing database and settings will be migrated automatically on first start.
+>
+> #### Notes
+>
+> - Image caching is enabled for Hardcover content.
+> - Chaptarr support is planned for a future release.
diff --git a/SECURITY.md b/SECURITY.md
index 5f20209c32..b0a6eb00c0 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -12,44 +12,35 @@ To report a security issue, please use the GitHub Security Advisory ["Report a V
> [!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.
+> Automated AI-generated reports 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 reports that could have been avoided with proper human oversight.
>
-> If you are using **any kind of AI assistance** to contribute to Seerr,
-> it must be disclosed in the pull request.
+> While we have no issue with reporters using AI tools as an aid, it is your responsibility as a reporter to ensure that all reports are carefully reviewed and meet our quality standards. Contributors who repeatedly submit unreviewed, low-effort AI output may be banned.
+>
+> We expect AI-assisted work, not AI-driven work. Use AI as a tool, not as a stand-in for your own review. It does not matter who submits it. Whether it's an agent acting on its own or a human relaying its output unread. What matters is whether you reviewed, verified, and understood your report or response before you sent it.
+>
+> If you are using **any kind of AI assistance** for a security report or response, it must be disclosed in your report.
-If you are using any kind of AI assistance while contributing to Seerr,
-**this must be disclosed in the pull request**, along with the extent to
-which AI assistance was used (e.g. docs only vs. code generation).
-If security advisory responses are being generated by an AI, disclose that as well.
-As a small exception, trivial tab-completion doesn't need to be disclosed,
-so long as it is limited to single keywords or short phrases.
+If you are using any kind of AI assistance for a security report or response, this must be disclosed, along with the extent to which AI assistance was used (e.g. used to help investigate the issue vs. used to write the report itself).
-An example disclosure:
+Security advisory responses must be written in your own words, same as described below for the meat proxy problem. The one exception is AI-assisted translation, if you use AI to translate a response into English, disclose that here too. As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases.
-> This security advisory was written primarily by Claude Code.
+**Disclosure does not exempt a report or response from this policy.** If a security report or advisory response was primarily generated by an AI tool with little more than a copy and paste on your end, that is still AI-driven, whether or not you disclosed it.
+
+A more detailed disclosure that meets our bar:
-Or a more detailed disclosure:
+> I consulted ChatGPT to understand the codebase but the solution was fully authored manually by myself.
+
+A disclosure like the one below is honest, but the response will still be treated as AI-driven:
+
+> This security advisory was written primarily by Claude Code.
-> I consulted ChatGPT to understand the codebase but the solution
-> was fully authored manually by myself.
+Failure to disclose this is first and foremost rude to the human operators on the other end of the report, but it also makes it difficult to determine how much scrutiny to apply to the submission.
-Failure to disclose this is first and foremost rude to the human operators
-on the other end of the pull request, but it also makes it difficult to
-determine how much scrutiny to apply to the contribution.
+Don't be a meat proxy between an AI and the security team either (the term is borrowed from [this post](https://gruhn.me/blog/2026-08-03/)). If we ask a follow-up question about your report and you relay it to an AI, then paste back an answer you haven't read or checked against the actual code, you haven't added anything, we could have asked the AI ourselves. We are volunteers handling this on our own time. Read the response, verify it, and write your own answer.
-In a perfect world, AI assistance would produce equal or higher quality
-work than any human. That isn't the world we live in today, and in most cases
-it's generating slop. I say this despite being a fan of and using them
-successfully myself (with heavy supervision)!
+In a perfect world, AI assistance would produce equal or higher quality work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)!
-When using AI assistance, we expect contributors to understand the code
-that is produced and be able to answer critical questions about it. It
-isn't a maintainers job to review a PR so broken that it requires
-significant rework to be acceptable.
+When using AI assistance, we expect reporters to understand the findings they're submitting and be able to answer critical questions about them. It isn't a maintainer's job to review a report so broken that it requires significant rework to be acceptable.
Please be respectful to maintainers and disclose AI assistance.
diff --git a/bin/duplicate-detector/.gitignore b/bin/duplicate-detector/.gitignore
index c2658d7d1b..7aa28f880f 100644
--- a/bin/duplicate-detector/.gitignore
+++ b/bin/duplicate-detector/.gitignore
@@ -1 +1,2 @@
node_modules/
+issue_index.json
diff --git a/bin/duplicate-detector/build-index.mjs b/bin/duplicate-detector/build-index.mjs
index 6fb5c8091e..a6c4a58ae8 100644
--- a/bin/duplicate-detector/build-index.mjs
+++ b/bin/duplicate-detector/build-index.mjs
@@ -10,9 +10,8 @@
import { pipeline } from '@huggingface/transformers';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
-import { fetchIssues, issueText } from './utils.mjs';
+import { EMBEDDING, fetchIssues, issueText } from './utils.mjs';
-const MODEL_NAME = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2';
const OUTPUT_PATH = 'issue_index.json';
const INCLUDE_CLOSED_DAYS = 90;
const MAX_ISSUES = 5000;
@@ -56,8 +55,8 @@ async function main() {
return;
}
- console.log(`Loading model: ${MODEL_NAME}`);
- const extractor = await pipeline('feature-extraction', MODEL_NAME, {
+ console.log(`Loading model: ${EMBEDDING.model}`);
+ const extractor = await pipeline('feature-extraction', EMBEDDING.model, {
dtype: 'fp32',
});
@@ -68,7 +67,7 @@ async function main() {
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const output = await extractor(batch, {
- pooling: 'mean',
+ pooling: EMBEDDING.pooling,
normalize: true,
});
@@ -96,7 +95,7 @@ async function main() {
const indexData = {
issues: issueMetadata,
embeddings: allEmbeddings,
- model: MODEL_NAME,
+ model: EMBEDDING.model,
issue_count: issueMetadata.length,
built_at: new Date().toISOString(),
};
diff --git a/bin/duplicate-detector/detect.mjs b/bin/duplicate-detector/detect.mjs
index 1b002b4a4f..aa9fde31ea 100644
--- a/bin/duplicate-detector/detect.mjs
+++ b/bin/duplicate-detector/detect.mjs
@@ -10,6 +10,7 @@
import { pipeline } from '@huggingface/transformers';
import { existsSync, readFileSync } from 'node:fs';
import {
+ EMBEDDING,
addLabel,
dotProduct,
fetchIssues,
@@ -18,10 +19,8 @@ import {
postComment,
} from './utils.mjs';
-const SIMILARITY_THRESHOLD = 0.55;
const TOP_K = 5;
const MAX_COMMENT_CANDIDATES = 3;
-const MODEL_NAME = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2';
const GROQ_MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile';
const INDEX_PATH = 'issue_index.json';
const LABEL_NAME = 'possible-duplicate';
@@ -45,7 +44,7 @@ function loadIndex(path) {
function findSimilar(
queryEmbedding,
index,
- { topK = TOP_K, threshold = SIMILARITY_THRESHOLD, excludeNumber } = {}
+ { topK = TOP_K, threshold = EMBEDDING.threshold, excludeNumber } = {}
) {
const { issues, embeddings } = index;
if (!issues.length) return [];
@@ -80,8 +79,8 @@ Example:
async function confirmWithLlm(newIssue, candidates) {
if (!GROQ_API_KEY) {
- console.warn('GROQ_API_KEY not set — skipping LLM confirmation');
- return candidates;
+ console.warn('GROQ_API_KEY not set - posting unconfirmed candidates');
+ return candidates.map((c) => ({ ...c, unconfirmed: true }));
}
const candidateText = candidates
@@ -161,17 +160,20 @@ async function confirmWithLlm(newIssue, candidates) {
return confirmed;
} catch (err) {
console.warn(
- `LLM confirmation failed: ${err.message} - falling back to all candidates`
+ `LLM confirmation failed: ${err.message} - posting unconfirmed candidates`
);
- return candidates;
+ return candidates.map((c) => ({ ...c, unconfirmed: true }));
}
}
function formatComment(candidates) {
+ const method = candidates.some((c) => c.unconfirmed)
+ ? 'semantic similarity only, LLM review was unavailable'
+ : 'semantic similarity + LLM review';
const lines = [
'**Possible duplicate detected**',
'',
- 'This issue may be a duplicate of the following (detected via semantic similarity + LLM review):',
+ `This issue may be a duplicate of the following (detected via ${method}):`,
'',
];
@@ -227,19 +229,29 @@ async function main() {
return;
}
- console.log(`Loading model: ${MODEL_NAME}`);
- const extractor = await pipeline('feature-extraction', MODEL_NAME, {
+ const index = loadIndex(INDEX_PATH);
+ if (index.model !== EMBEDDING.model) {
+ console.log(
+ `Index was built with ${index.model}, expected ${EMBEDDING.model} - skipping`
+ );
+ return;
+ }
+
+ console.log(`Loading model: ${EMBEDDING.model}`);
+ const extractor = await pipeline('feature-extraction', EMBEDDING.model, {
dtype: 'fp32',
});
- const index = loadIndex(INDEX_PATH);
const text = issueText(issue.title, issue.body);
- const output = await extractor(text, { pooling: 'mean', normalize: true });
+ const output = await extractor([text], {
+ pooling: EMBEDDING.pooling,
+ normalize: true,
+ });
const queryEmbedding = output.tolist()[0];
let candidates = findSimilar(queryEmbedding, index, {
topK: TOP_K,
- threshold: SIMILARITY_THRESHOLD,
+ threshold: EMBEDDING.threshold,
excludeNumber: issue.number,
});
diff --git a/bin/duplicate-detector/package.json b/bin/duplicate-detector/package.json
index f61aa7093c..18d39bba82 100644
--- a/bin/duplicate-detector/package.json
+++ b/bin/duplicate-detector/package.json
@@ -9,7 +9,7 @@
"detect": "node detect.mjs"
},
"dependencies": {
- "@huggingface/transformers": "^3.8.1"
+ "@huggingface/transformers": "^4.0.0"
},
"engines": {
"node": ">=22.0"
diff --git a/bin/duplicate-detector/pnpm-lock.yaml b/bin/duplicate-detector/pnpm-lock.yaml
index 5610d9b179..69070f427e 100644
--- a/bin/duplicate-detector/pnpm-lock.yaml
+++ b/bin/duplicate-detector/pnpm-lock.yaml
@@ -9,162 +9,170 @@ importers:
.:
dependencies:
'@huggingface/transformers':
- specifier: ^3.8.1
- version: 3.8.1
+ specifier: ^4.0.0
+ version: 4.3.0
packages:
- '@emnapi/runtime@1.8.1':
- resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
- '@huggingface/jinja@0.5.5':
- resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==}
+ '@huggingface/jinja@0.5.10':
+ resolution: {integrity: sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==}
engines: {node: '>=18'}
- '@huggingface/transformers@3.8.1':
- resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==}
+ '@huggingface/tokenizers@0.2.0':
+ resolution: {integrity: sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg==}
- '@img/colour@1.0.0':
- resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
+ '@huggingface/transformers@4.3.0':
+ resolution: {integrity: sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA==}
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.34.5':
- resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-arm64@0.35.4':
+ resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.5':
- resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-x64@0.35.4':
+ resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ '@img/sharp-freebsd-wasm32@0.35.4':
+ resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.3':
+ resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.4':
- resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ '@img/sharp-libvips-darwin-x64@1.3.3':
+ resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.2.4':
- resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ '@img/sharp-libvips-linux-arm64@1.3.3':
+ resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64]
os: [linux]
- '@img/sharp-libvips-linux-arm@1.2.4':
- resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ '@img/sharp-libvips-linux-arm@1.3.3':
+ resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm]
os: [linux]
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ '@img/sharp-libvips-linux-ppc64@1.3.3':
+ resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64]
os: [linux]
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ '@img/sharp-libvips-linux-riscv64@1.3.3':
+ resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64]
os: [linux]
- '@img/sharp-libvips-linux-s390x@1.2.4':
- resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.3':
+ resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x]
os: [linux]
- '@img/sharp-libvips-linux-x64@1.2.4':
- resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ '@img/sharp-libvips-linux-x64@1.3.3':
+ resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64]
os: [linux]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
+ resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64]
os: [linux]
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
+ resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64]
os: [linux]
- '@img/sharp-linux-arm64@0.34.5':
- resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm64@0.35.4':
+ resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
- '@img/sharp-linux-arm@0.34.5':
- resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm@0.35.4':
+ resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
+ engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
- '@img/sharp-linux-ppc64@0.34.5':
- resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-ppc64@0.35.4':
+ resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
+ engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
- '@img/sharp-linux-riscv64@0.34.5':
- resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-riscv64@0.35.4':
+ resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
+ engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
- '@img/sharp-linux-s390x@0.34.5':
- resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-s390x@0.35.4':
+ resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
+ engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
- '@img/sharp-linux-x64@0.34.5':
- resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-x64@0.35.4':
+ resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
- '@img/sharp-linuxmusl-arm64@0.34.5':
- resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-arm64@0.35.4':
+ resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
- '@img/sharp-linuxmusl-x64@0.34.5':
- resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-x64@0.35.4':
+ resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
- '@img/sharp-wasm32@0.34.5':
- resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-wasm32@0.35.4':
+ resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.4':
+ resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
+ engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.5':
- resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-arm64@0.35.4':
+ resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.34.5':
- resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-ia32@0.35.4':
+ resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
+ engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.34.5':
- resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-x64@0.35.4':
+ resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
- '@isaacs/fs-minipass@4.0.1':
- resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
- engines: {node: '>=18.0.0'}
-
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -198,13 +206,9 @@ packages:
'@types/node@25.2.2':
resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==}
- boolean@3.2.0:
- resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
- deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
-
- chownr@3.0.0:
- resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
- engines: {node: '>=18'}
+ adm-zip@0.6.1:
+ resolution: {integrity: sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==}
+ engines: {node: '>=14.0'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
@@ -218,9 +222,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
- detect-node@2.1.0:
- resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
-
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -229,9 +230,6 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
- es6-error@4.1.1:
- resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
-
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -239,8 +237,8 @@ packages:
flatbuffers@25.9.23:
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
- global-agent@3.0.0:
- resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
+ global-agent@4.1.3:
+ resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==}
engines: {node: '>=10.0'}
globalthis@1.0.4:
@@ -257,40 +255,29 @@ packages:
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
- json-stringify-safe@5.0.1:
- resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
-
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
- matcher@3.0.0:
- resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
+ matcher@4.0.0:
+ resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==}
engines: {node: '>=10'}
- minipass@7.1.2:
- resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
- engines: {node: '>=16 || 14 >=14.17'}
-
- minizlib@3.1.0:
- resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
- engines: {node: '>= 18'}
-
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
- onnxruntime-common@1.21.0:
- resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==}
+ onnxruntime-common@1.30.0:
+ resolution: {integrity: sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==}
- onnxruntime-common@1.22.0-dev.20250409-89f8206ba4:
- resolution: {integrity: sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==}
+ onnxruntime-common@1.31.0-dev.20260911-2a43ec07e:
+ resolution: {integrity: sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw==}
- onnxruntime-node@1.21.0:
- resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==}
+ onnxruntime-node@1.30.0:
+ resolution: {integrity: sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw==}
os: [win32, darwin, linux]
- onnxruntime-web@1.22.0-dev.20250409-89f8206ba4:
- resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==}
+ onnxruntime-web@1.31.0-dev.20260914-8d85527a0:
+ resolution: {integrity: sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g==}
platform@1.3.6:
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
@@ -299,162 +286,160 @@ packages:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
- roarr@2.15.4:
- resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
- engines: {node: '>=8.0'}
-
- semver-compare@1.0.0:
- resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
-
- semver@7.7.4:
- resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
- serialize-error@7.0.1:
- resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
+ serialize-error@8.1.0:
+ resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==}
engines: {node: '>=10'}
- sharp@0.34.5:
- resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
-
- sprintf-js@1.1.3:
- resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
-
- tar@7.5.7:
- resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
- engines: {node: '>=18'}
+ sharp@0.35.4:
+ resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
+ engines: {node: '>=20.9.0'}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
- type-fest@0.13.1:
- resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
+ type-fest@0.20.2:
+ resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
- yallist@5.0.0:
- resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
- engines: {node: '>=18'}
-
snapshots:
- '@emnapi/runtime@1.8.1':
+ '@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
- '@huggingface/jinja@0.5.5': {}
+ '@huggingface/jinja@0.5.10': {}
- '@huggingface/transformers@3.8.1':
+ '@huggingface/tokenizers@0.2.0': {}
+
+ '@huggingface/transformers@4.3.0':
dependencies:
- '@huggingface/jinja': 0.5.5
- onnxruntime-node: 1.21.0
- onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4
- sharp: 0.34.5
+ '@huggingface/jinja': 0.5.10
+ '@huggingface/tokenizers': 0.2.0
+ onnxruntime-node: 1.30.0
+ onnxruntime-web: 1.31.0-dev.20260914-8d85527a0
+ sharp: 0.35.4
+ transitivePeerDependencies:
+ - '@types/node'
- '@img/colour@1.0.0': {}
+ '@img/colour@1.1.0': {}
- '@img/sharp-darwin-arm64@0.34.5':
+ '@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true
- '@img/sharp-darwin-x64@0.34.5':
+ '@img/sharp-darwin-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.3.3
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.4':
+ '@img/sharp-freebsd-wasm32@0.35.4':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-libvips-darwin-x64@1.2.4':
+ '@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm64@1.2.4':
+ '@img/sharp-libvips-darwin-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm@1.2.4':
+ '@img/sharp-libvips-linux-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.2.4':
+ '@img/sharp-libvips-linux-arm@1.3.3':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.2.4':
+ '@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-s390x@1.2.4':
+ '@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-x64@1.2.4':
+ '@img/sharp-libvips-linux-s390x@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ '@img/sharp-libvips-linux-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true
- '@img/sharp-linux-arm64@0.34.5':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.3.3
optional: true
- '@img/sharp-linux-arm@0.34.5':
+ '@img/sharp-linux-arm@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.3.3
optional: true
- '@img/sharp-linux-ppc64@0.34.5':
+ '@img/sharp-linux-ppc64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true
- '@img/sharp-linux-riscv64@0.34.5':
+ '@img/sharp-linux-riscv64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true
- '@img/sharp-linux-s390x@0.34.5':
+ '@img/sharp-linux-s390x@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.3.3
optional: true
- '@img/sharp-linux-x64@0.34.5':
+ '@img/sharp-linux-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-arm64@0.34.5':
+ '@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-x64@0.34.5':
+ '@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true
- '@img/sharp-wasm32@0.34.5':
+ '@img/sharp-wasm32@0.35.4':
dependencies:
- '@emnapi/runtime': 1.8.1
+ '@emnapi/runtime': 1.11.3
optional: true
- '@img/sharp-win32-arm64@0.34.5':
+ '@img/sharp-webcontainers-wasm32@0.35.4':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-win32-ia32@0.34.5':
+ '@img/sharp-win32-arm64@0.35.4':
optional: true
- '@img/sharp-win32-x64@0.34.5':
+ '@img/sharp-win32-ia32@0.35.4':
optional: true
- '@isaacs/fs-minipass@4.0.1':
- dependencies:
- minipass: 7.1.2
+ '@img/sharp-win32-x64@0.35.4':
+ optional: true
'@protobufjs/aspromise@1.1.2': {}
@@ -483,9 +468,7 @@ snapshots:
dependencies:
undici-types: 7.16.0
- boolean@3.2.0: {}
-
- chownr@3.0.0: {}
+ adm-zip@0.6.1: {}
define-data-property@1.1.4:
dependencies:
@@ -501,26 +484,20 @@ snapshots:
detect-libc@2.1.2: {}
- detect-node@2.1.0: {}
-
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
- es6-error@4.1.1: {}
-
escape-string-regexp@4.0.0: {}
flatbuffers@25.9.23: {}
- global-agent@3.0.0:
+ global-agent@4.1.3:
dependencies:
- boolean: 3.2.0
- es6-error: 4.1.1
- matcher: 3.0.0
- roarr: 2.15.4
- semver: 7.7.4
- serialize-error: 7.0.1
+ globalthis: 1.0.4
+ matcher: 4.0.0
+ semver: 7.8.5
+ serialize-error: 8.1.0
globalthis@1.0.4:
dependencies:
@@ -535,38 +512,30 @@ snapshots:
dependencies:
es-define-property: 1.0.1
- json-stringify-safe@5.0.1: {}
-
long@5.3.2: {}
- matcher@3.0.0:
+ matcher@4.0.0:
dependencies:
escape-string-regexp: 4.0.0
- minipass@7.1.2: {}
-
- minizlib@3.1.0:
- dependencies:
- minipass: 7.1.2
-
object-keys@1.1.1: {}
- onnxruntime-common@1.21.0: {}
+ onnxruntime-common@1.30.0: {}
- onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {}
+ onnxruntime-common@1.31.0-dev.20260911-2a43ec07e: {}
- onnxruntime-node@1.21.0:
+ onnxruntime-node@1.30.0:
dependencies:
- global-agent: 3.0.0
- onnxruntime-common: 1.21.0
- tar: 7.5.7
+ adm-zip: 0.6.1
+ global-agent: 4.1.3
+ onnxruntime-common: 1.30.0
- onnxruntime-web@1.22.0-dev.20250409-89f8206ba4:
+ onnxruntime-web@1.31.0-dev.20260914-8d85527a0:
dependencies:
flatbuffers: 25.9.23
guid-typescript: 1.0.9
long: 5.3.2
- onnxruntime-common: 1.22.0-dev.20250409-89f8206ba4
+ onnxruntime-common: 1.31.0-dev.20260911-2a43ec07e
platform: 1.3.6
protobufjs: 7.5.4
@@ -587,69 +556,47 @@ snapshots:
'@types/node': 25.2.2
long: 5.3.2
- roarr@2.15.4:
- dependencies:
- boolean: 3.2.0
- detect-node: 2.1.0
- globalthis: 1.0.4
- json-stringify-safe: 5.0.1
- semver-compare: 1.0.0
- sprintf-js: 1.1.3
+ semver@7.8.5: {}
- semver-compare@1.0.0: {}
-
- semver@7.7.4: {}
-
- serialize-error@7.0.1:
+ serialize-error@8.1.0:
dependencies:
- type-fest: 0.13.1
+ type-fest: 0.20.2
- sharp@0.34.5:
+ sharp@0.35.4:
dependencies:
- '@img/colour': 1.0.0
+ '@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.7.4
+ semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-libvips-darwin-arm64': 1.2.4
- '@img/sharp-libvips-darwin-x64': 1.2.4
- '@img/sharp-libvips-linux-arm': 1.2.4
- '@img/sharp-libvips-linux-arm64': 1.2.4
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- '@img/sharp-libvips-linux-s390x': 1.2.4
- '@img/sharp-libvips-linux-x64': 1.2.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-ppc64': 0.34.5
- '@img/sharp-linux-riscv64': 0.34.5
- '@img/sharp-linux-s390x': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-wasm32': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-ia32': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
-
- sprintf-js@1.1.3: {}
-
- tar@7.5.7:
- dependencies:
- '@isaacs/fs-minipass': 4.0.1
- chownr: 3.0.0
- minipass: 7.1.2
- minizlib: 3.1.0
- yallist: 5.0.0
+ '@img/sharp-darwin-arm64': 0.35.4
+ '@img/sharp-darwin-x64': 0.35.4
+ '@img/sharp-freebsd-wasm32': 0.35.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.3
+ '@img/sharp-libvips-darwin-x64': 1.3.3
+ '@img/sharp-libvips-linux-arm': 1.3.3
+ '@img/sharp-libvips-linux-arm64': 1.3.3
+ '@img/sharp-libvips-linux-ppc64': 1.3.3
+ '@img/sharp-libvips-linux-riscv64': 1.3.3
+ '@img/sharp-libvips-linux-s390x': 1.3.3
+ '@img/sharp-libvips-linux-x64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
+ '@img/sharp-linux-arm': 0.35.4
+ '@img/sharp-linux-arm64': 0.35.4
+ '@img/sharp-linux-ppc64': 0.35.4
+ '@img/sharp-linux-riscv64': 0.35.4
+ '@img/sharp-linux-s390x': 0.35.4
+ '@img/sharp-linux-x64': 0.35.4
+ '@img/sharp-linuxmusl-arm64': 0.35.4
+ '@img/sharp-linuxmusl-x64': 0.35.4
+ '@img/sharp-webcontainers-wasm32': 0.35.4
+ '@img/sharp-win32-arm64': 0.35.4
+ '@img/sharp-win32-ia32': 0.35.4
+ '@img/sharp-win32-x64': 0.35.4
tslib@2.8.1:
optional: true
- type-fest@0.13.1: {}
+ type-fest@0.20.2: {}
undici-types@7.16.0: {}
-
- yallist@5.0.0: {}
diff --git a/bin/duplicate-detector/utils.mjs b/bin/duplicate-detector/utils.mjs
index 4f20b85f9f..6117f12511 100644
--- a/bin/duplicate-detector/utils.mjs
+++ b/bin/duplicate-detector/utils.mjs
@@ -1,3 +1,9 @@
+export const EMBEDDING = {
+ model: 'Xenova/bge-small-en-v1.5',
+ pooling: 'cls',
+ threshold: 0.82,
+};
+
const GITHUB_API = 'https://api.github.com';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY;
@@ -12,6 +18,7 @@ function ghHeaders() {
export async function fetchIssues({
state = 'open',
since,
+ creator,
maxIssues = 5000,
} = {}) {
const issues = [];
@@ -27,6 +34,7 @@ export async function fetchIssues({
direction: 'desc',
});
if (since) params.set('since', since);
+ if (creator) params.set('creator', creator);
const url = `${GITHUB_API}/repos/${GITHUB_REPOSITORY}/issues?${params}`;
const resp = await fetch(url, { headers: ghHeaders() });
@@ -101,8 +109,12 @@ export async function addLabel(issueNumber, label) {
console.log(`Added label '${label}' to #${issueNumber}`);
}
+const FORM_NOISE = [/^###\s.*$/gm, /^_No response_$/gm, /^- \[[ xX]\] .*$/gm];
+
export function issueText(title, body) {
body = (body || '').trim();
+ for (const pattern of FORM_NOISE) body = body.replace(pattern, '');
+ body = body.replace(/\n{3,}/g, '\n\n').trim();
if (body.length > 2000) body = body.slice(0, 2000) + '...';
return body ? `${title}\n\n${body}` : title;
}
diff --git a/bin/prepare.js b/bin/prepare.mjs
similarity index 63%
rename from bin/prepare.js
rename to bin/prepare.mjs
index 5f19878a1d..3d195c4e23 100644
--- a/bin/prepare.js
+++ b/bin/prepare.mjs
@@ -5,5 +5,6 @@
*/
const isCi = process.env.CI !== undefined;
if (!isCi) {
- require('husky').install();
+ const husky = (await import('husky')).default;
+ console.log(husky());
}
diff --git a/charts/seerr-chart/Chart.yaml b/charts/seerr-chart/Chart.yaml
index 88d156b4bf..7a568eac6a 100644
--- a/charts/seerr-chart/Chart.yaml
+++ b/charts/seerr-chart/Chart.yaml
@@ -3,9 +3,9 @@ kubeVersion: '>=1.23.0-0'
name: seerr-chart
description: Seerr helm chart for Kubernetes
type: application
-version: 3.6.0
+version: 3.9.1
# renovate: image=ghcr.io/seerr-team/seerr
-appVersion: 'v3.2.0'
+appVersion: 'v3.4.1'
maintainers:
- name: Seerr Team
url: https://github.com/orgs/seerr-team/people
diff --git a/charts/seerr-chart/README.md b/charts/seerr-chart/README.md
index a41c6e7ace..b3df682520 100644
--- a/charts/seerr-chart/README.md
+++ b/charts/seerr-chart/README.md
@@ -1,6 +1,6 @@
# seerr-chart
-  
+  
Seerr helm chart for Kubernetes
@@ -53,9 +53,12 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| config.persistence.storageClass | string | `""` | Storage class for the PVC. Set to "-" to disable dynamic provisioning. Uses default storage class if no value is provided |
| config.persistence.subPath | string | `""` | Subpath of the pvc which should be mounted |
| config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. |
+| dnsConfig | object | `{}` | docs: https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-dns-config |
+| dnsPolicy | string | `""` | docs: https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-policy |
| extraEnv | list | `[]` | Environment variables to add to the seerr pods |
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the seerr pods |
| fullnameOverride | string | `""` | |
+| hostUsers | bool | `true` | docs: https://kubernetes.io/docs/concepts/workloads/pods/user-namespaces/ |
| image.pullPolicy | string | `"IfNotPresent"` | |
| image.registry | string | `"ghcr.io"` | |
| image.repository | string | `"seerr-team/seerr"` | |
@@ -75,6 +78,7 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| podLabels | object | `{}` | |
| podSecurityContext.fsGroup | int | `1000` | |
| podSecurityContext.fsGroupChangePolicy | string | `"OnRootMismatch"` | |
+| priorityClassName | string | `nil` | Specify a priorityclass, or use default if unset. |
| probes.livenessProbe | object | `{"initialDelaySeconds":20,"periodSeconds":15,"timeoutSeconds":3}` | Configure liveness probe |
| probes.readinessProbe | object | `{"initialDelaySeconds":60,"periodSeconds":15,"timeoutSeconds":3}` | Configure readiness probe |
| probes.startupProbe | string | `nil` | Configure startup probe |
@@ -99,9 +103,9 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| securityContext.runAsNonRoot | bool | `true` | |
| securityContext.runAsUser | int | `1000` | |
| securityContext.seccompProfile.type | string | `"RuntimeDefault"` | |
+| service.annotations | object | `{}` | |
| service.port | int | `80` | |
| service.type | string | `"ClusterIP"` | |
-| service.annotations | object | {} | |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
| serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? |
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
diff --git a/charts/seerr-chart/templates/statefulset.yaml b/charts/seerr-chart/templates/statefulset.yaml
index 1888ea4199..1ce545fff2 100644
--- a/charts/seerr-chart/templates/statefulset.yaml
+++ b/charts/seerr-chart/templates/statefulset.yaml
@@ -28,6 +28,19 @@ spec:
serviceAccountName: {{ include "seerr.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
+ {{- with .Values.priorityClassName }}
+ priorityClassName: {{ . }}
+ {{- end }}
+ {{- if kindIs "bool" .Values.hostUsers }}
+ hostUsers: {{ .Values.hostUsers }}
+ {{- end }}
+ {{- with .Values.dnsPolicy }}
+ dnsPolicy: {{ . }}
+ {{- end }}
+ {{- with .Values.dnsConfig }}
+ dnsConfig:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
diff --git a/charts/seerr-chart/values.yaml b/charts/seerr-chart/values.yaml
index 5321f44a7a..adc8a97651 100644
--- a/charts/seerr-chart/values.yaml
+++ b/charts/seerr-chart/values.yaml
@@ -67,6 +67,31 @@ securityContext:
seccompProfile:
type: RuntimeDefault
+# -- Set a DNS policy, likely not needed unless seerr only interacts with external services.
+# -- docs: https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-policy
+dnsPolicy: ''
+
+# -- Set DNS config options
+# -- Setting ndots lower can prevent queries like `raw.githubusercontent.com.seerr.svc.cluster.local.`
+# -- docs: https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-dns-config
+dnsConfig: {}
+# nameservers:
+# - 1.2.3.4
+# searches:
+# - ns1.svc.cluster-domain.example
+# - my.dns.search.suffix
+# options:
+# - name: ndots
+# value: "2"
+
+# -- Set to false to run in an idmaped user namespace for additional isolation
+# -- requires cluster support, and PV support of idmaped volumes.
+# -- docs: https://kubernetes.io/docs/concepts/workloads/pods/user-namespaces/
+hostUsers: true
+
+# -- Specify a priorityclass, or use default if unset.
+priorityClassName: null
+
service:
type: ClusterIP
port: 80
diff --git a/cypress.config.ts b/cypress.config.ts
index 0fa88d2f9a..5db5e200ba 100644
--- a/cypress.config.ts
+++ b/cypress.config.ts
@@ -9,7 +9,7 @@ export default defineConfig({
env: {
ADMIN_EMAIL: 'admin@seerr.dev',
ADMIN_PASSWORD: 'test1234',
- USER_EMAIL: 'friend@seerr.dev',
+ USER_EMAIL: 'demo@seerr.dev',
USER_PASSWORD: 'test1234',
},
retries: {
diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts
index 52a5e883a1..66bc7713c9 100644
--- a/cypress/e2e/discover.cy.ts
+++ b/cypress/e2e/discover.cy.ts
@@ -145,7 +145,7 @@ describe('Discover', () => {
requestedBy: {
permissions: 4194336,
id: 18,
- email: 'friend@seerr.dev',
+ email: 'demo@seerr.dev',
plexUsername: null,
username: '',
recoveryLinkExpirationDate: null,
@@ -159,7 +159,7 @@ describe('Discover', () => {
createdAt: '2022-08-17T04:55:28.000Z',
updatedAt: '2022-08-17T04:55:28.000Z',
requestCount: 1,
- displayName: 'friend@seerr.dev',
+ displayName: 'demo@seerr.dev',
},
seasonCount: 0,
},
diff --git a/cypress/e2e/movie-details.cy.ts b/cypress/e2e/movie-details.cy.ts
index 1d3ecf3f13..0c2eeea56d 100644
--- a/cypress/e2e/movie-details.cy.ts
+++ b/cypress/e2e/movie-details.cy.ts
@@ -9,4 +9,20 @@ describe('Movie Details', () => {
'Minions: The Rise of Gru (2022)'
);
});
+
+ it('does not reopen the manager panel after closing and going back', () => {
+ cy.loginAsAdmin();
+
+ cy.visit('/movie/438148');
+ cy.visit('/movie/438148?manage=1');
+
+ cy.get('button[aria-label="Close panel"]').should('be.visible').click();
+ cy.location('search').should('eq', '');
+ cy.get('button[aria-label="Close panel"]').should('not.exist');
+
+ cy.go('back');
+
+ cy.location('search').should('eq', '');
+ cy.get('button[aria-label="Close panel"]').should('not.exist');
+ });
});
diff --git a/cypress/e2e/settings/general-settings.cy.ts b/cypress/e2e/settings/general-settings.cy.ts
index 59649ed6b1..114e37a5f9 100644
--- a/cypress/e2e/settings/general-settings.cy.ts
+++ b/cypress/e2e/settings/general-settings.cy.ts
@@ -3,6 +3,11 @@ describe('General Settings', () => {
cy.loginAsAdmin();
});
+ // A mid-test failure skips the in-test revert, leaving the restart modal up for every later spec.
+ afterEach(() => {
+ cy.request('POST', '/api/v1/settings/network', { trustProxy: false });
+ });
+
it('opens the settings page from the home page', () => {
cy.visit('/');
diff --git a/devenv.lock b/devenv.lock
new file mode 100644
index 0000000000..0a451c2da9
--- /dev/null
+++ b/devenv.lock
@@ -0,0 +1,65 @@
+{
+ "nodes": {
+ "devenv": {
+ "locked": {
+ "dir": "src/modules",
+ "lastModified": 1780991472,
+ "narHash": "sha256-5SglTZTbq5xyrhgH0+1xIZ92lC+W1aNMm9tEvFt4k9U=",
+ "owner": "cachix",
+ "repo": "devenv",
+ "rev": "822871663d1568432b8eef86a714352519b158b8",
+ "type": "github"
+ },
+ "original": {
+ "dir": "src/modules",
+ "owner": "cachix",
+ "repo": "devenv",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "inputs": {
+ "nixpkgs-src": "nixpkgs-src"
+ },
+ "locked": {
+ "lastModified": 1778507786,
+ "narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
+ "owner": "cachix",
+ "repo": "devenv-nixpkgs",
+ "rev": "8f24a228a782e24576b155d1e39f0d914b380691",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "rolling",
+ "repo": "devenv-nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-src": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1778274207,
+ "narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "devenv": "devenv",
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
\ No newline at end of file
diff --git a/devenv.nix b/devenv.nix
new file mode 100644
index 0000000000..7c1c502af8
--- /dev/null
+++ b/devenv.nix
@@ -0,0 +1,24 @@
+{
+ pkgs,
+ lib,
+ config,
+ ...
+}:
+{
+ packages = [
+ pkgs.prettier
+ pkgs.husky
+ pkgs.helm-docs
+ ];
+
+ languages.javascript = {
+ enable = true;
+ package = pkgs.nodejs_22;
+ nodejs.enable = true;
+ pnpm = {
+ enable = true;
+ package = pkgs.pnpm_10;
+ install.enable = true;
+ };
+ };
+}
diff --git a/devenv.yaml b/devenv.yaml
new file mode 100644
index 0000000000..6bf1e6c17d
--- /dev/null
+++ b/devenv.yaml
@@ -0,0 +1,3 @@
+inputs:
+ nixpkgs:
+ url: github:cachix/devenv-nixpkgs/rolling
diff --git a/docs/api/_category_.json b/docs/api/_category_.json
deleted file mode 100644
index 7543e68319..0000000000
--- a/docs/api/_category_.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "label": "REST API documentation",
- "position": 4,
- "link": {
- "type": "generated-index",
- "title": "REST API documentation",
- "description": "Create integrations with the Seerr REST API."
- }
-}
diff --git a/docs/extending-seerr/database-config.mdx b/docs/extending-seerr/database-config.mdx
index edf101afd6..0d3dc8e687 100644
--- a/docs/extending-seerr/database-config.mdx
+++ b/docs/extending-seerr/database-config.mdx
@@ -36,6 +36,7 @@ DB_USER= # (required) Username used to connect to the database.
DB_PASS= # (required) Password of the user used to connect to the database.
DB_NAME="seerr" # (optional) The name of the database to connect to. The default is "seerr".
DB_POOL_SIZE="10" # (optional) The number of connections to maintain in the connection pool. The default is "10".
+DB_CONNECT_TIMEOUT_MS="30000" # (optional) How long to wait for a free connection from the pool before erroring, in milliseconds. The default is "30000". Set to "0" to wait indefinitely.
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
@@ -50,6 +51,7 @@ DB_USER= # (required) Username used to connect to the database.
DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration.
DB_NAME="seerr" # (optional) The name of the database to connect to. The default is "seerr".
DB_POOL_SIZE="10" # (optional) The number of connections to maintain in the connection pool. The default is "10".
+DB_CONNECT_TIMEOUT_MS="30000" # (optional) How long to wait for a free connection from the pool before erroring, in milliseconds. The default is "30000". Set to "0" to wait indefinitely.
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
diff --git a/docs/getting-started/docker.mdx b/docs/getting-started/docker.mdx
index b6a12ec290..d4dba819cc 100644
--- a/docs/getting-started/docker.mdx
+++ b/docs/getting-started/docker.mdx
@@ -68,6 +68,8 @@ docker run -d \
--health-timeout 3s \
--health-interval 15s \
--health-retries 3 \
+ --security-opt no-new-privileges \
+ --cap-drop ALL \
ghcr.io/seerr-team/seerr:latest
```
@@ -123,6 +125,10 @@ services:
timeout: 3s
interval: 15s
retries: 3
+ security_opt:
+ - no-new-privileges
+ cap_drop:
+ - ALL
restart: unless-stopped
```
@@ -186,6 +192,8 @@ docker run -d `
--health-timeout 3s `
--health-interval 15s `
--health-retries 3 `
+ --security-opt no-new-privileges `
+ --cap-drop ALL `
ghcr.io/seerr-team/seerr:latest
```
@@ -224,6 +232,8 @@ docker run -d ^
--health-timeout 3s ^
--health-interval 15s ^
--health-retries 3 ^
+ --security-opt no-new-privileges ^
+ --cap-drop ALL ^
ghcr.io/seerr-team/seerr:latest
```
@@ -267,6 +277,10 @@ services:
timeout: 3s
interval: 15s
retries: 3
+ security_opt:
+ - no-new-privileges
+ cap_drop:
+ - ALL
restart: unless-stopped
volumes:
diff --git a/docs/getting-started/kubernetes.mdx b/docs/getting-started/kubernetes.mdx
index c55e3051d7..b92e767b3a 100644
--- a/docs/getting-started/kubernetes.mdx
+++ b/docs/getting-started/kubernetes.mdx
@@ -19,8 +19,3 @@ To confirm that the chart you are using is authentic and unmodified, please refe
helm install seerr oci://ghcr.io/seerr-team/seerr/seerr-chart
```
Helm values can be found in the Seerr repository under [charts/seerr-chart/README.md](https://github.com/seerr-team/seerr/tree/develop/charts/seerr-chart).
-
-Verify the signature with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) (replace [tag], with the TAG you want to verify) :
-```console
-cosign verify ghcr.io/seerr-team/seerr/seerr-chart:[tag] --certificate-identity=https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.actions.githubusercontent.com
-```
diff --git a/docs/getting-started/nixpkg.mdx b/docs/getting-started/nixpkg.mdx
index 43cee97cbb..f8d0c0fee6 100644
--- a/docs/getting-started/nixpkg.mdx
+++ b/docs/getting-started/nixpkg.mdx
@@ -12,15 +12,11 @@ import TabItem from '@theme/TabItem';
# Nix Package Manager
:::warning
This method is not recommended for most users. It is intended for advanced users who are using NixOS distribution.
+The Seerr service and package are available as of the 26.05 release.
:::
-:::danger
-The seerr service and package are available in the unstable channel only and will be officially included in the 26.05 release.
-:::
-
-Refer to [NixOS documentation](https://search.nixos.org/options?channel=unstable&query=seerr)
+Refer to [NixOS documentation](https://search.nixos.org/options?channel=26.05&query=seerr)
-
## Installation
To get up and running with seerr using Nix, you can add the following to your `configuration.nix`:
diff --git a/docs/migration-guide.mdx b/docs/migration-guide.mdx
index c15667ad0c..3486f38703 100644
--- a/docs/migration-guide.mdx
+++ b/docs/migration-guide.mdx
@@ -222,7 +222,7 @@ Summary of changes :
Refer to [Seerr Documentation](/getting-started/nixpkg), all of our examples have been updated to reflect the below change.
-The seerr service and package are available in the unstable channel and will be officially included in the 26.05 release.
+The Seerr service and package are available as of the 26.05 release.
## Third-party installation methods
@@ -238,6 +238,8 @@ See https://aur.archlinux.org/packages/seerr
Refer to [Seerr TrueNAS Documentation](/getting-started/third-parties/truenas), all of our examples have been updated to reflect the below change.
+For detailed community-maintained TrueNAS SCALE walkthroughs, see the [Host Path setup](https://github.com/truenas/apps/issues/3374#issuecomment-3916418872) and [ixVolume setup](https://github.com/truenas/apps/issues/3374#issuecomment-3916700870) guides.
+
**This guide describes how to migrate from Host Path storage (not ixVolume).**
@@ -265,7 +267,7 @@ Refer to [Seerr TrueNAS Documentation](/getting-started/third-parties/truenas),
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/
+ rsync -av --chown=568:568 /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
diff --git a/docs/using-seerr/advanced/verifying-signed-artifacts.mdx b/docs/using-seerr/advanced/verifying-signed-artifacts.mdx
index 3ed328676f..8c2d6ea1fd 100644
--- a/docs/using-seerr/advanced/verifying-signed-artifacts.mdx
+++ b/docs/using-seerr/advanced/verifying-signed-artifacts.mdx
@@ -298,7 +298,7 @@ sha256:abcd1234...
```bash
cosign verify ghcr.io/seerr-team/seerr/seerr-chart@sha256:abcd1234... \
- --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \
+ --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/develop" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```
@@ -316,13 +316,13 @@ The following checks were performed:
### Expected Certificate Identity
-The expected certificate identity for all signed Seerr images is:
+The expected certificate identity for all signed Seerr charts is:
```
-https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main
+https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/develop
```
-This confirms that the image was:
+This confirms that the chart was:
- Built by the official Seerr Release workflow
- Produced from the seerr-team/seerr repository
@@ -339,12 +339,12 @@ This confirms that the image was:
DIGEST=$(docker buildx imagetools inspect ghcr.io/seerr-team/seerr/seerr-chart:3.0.0 --format '{{json .Manifest.Digest}}' | tr -d '"')
cosign verify ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \
- --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \
+ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/develop" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
cosign verify-attestation ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \
--type cyclonedx \
- --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \
+ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/develop" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```
@@ -355,7 +355,7 @@ cosign verify-attestation ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \
DIGEST=$(skopeo inspect docker://ghcr.io/seerr-team/seerr/seerr-chart:3.0.0 --format '{{.Digest}}')
cosign verify ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \
- --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \
+ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/develop" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```
diff --git a/docs/using-seerr/notifications/discord.md b/docs/using-seerr/notifications/discord.md
index 3988d93ce3..680d9b1b64 100644
--- a/docs/using-seerr/notifications/discord.md
+++ b/docs/using-seerr/notifications/discord.md
@@ -22,6 +22,10 @@ You can find the webhook URL in the Discord application, at **Server Settings &r
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
+### Thread ID (optional)
+
+If a thread ID is specified, the notification will be sent to a specific thread, instead of the webhook channel. Leave it blank to send it directly to the channel.
+
### Bot Username (optional)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
@@ -36,4 +40,6 @@ When enabled, notifications will be sent in the language of the user who trigger
### Notification Language
-Sets the language for all notifications sent to this Discord channel. This option is only available when **Use Notification Recipient Locale** is disabled.
\ No newline at end of file
+Sets the language for all notifications sent to this Discord channel. This option is only available when **Use Notification Recipient Locale** is disabled.
+
+The thread must belong to the webhook's channel. A thread from a different channel will cause the request to fail and the notification will not be delivered.
diff --git a/docs/using-seerr/notifications/ntfy.md b/docs/using-seerr/notifications/ntfy.md
index 6aa4664923..2b153a2f94 100644
--- a/docs/using-seerr/notifications/ntfy.md
+++ b/docs/using-seerr/notifications/ntfy.md
@@ -16,6 +16,10 @@ Set this to the URL of your ntfy.sh server.
Set this to the topic you want to send notifications to.
+### Tags (optional)
+
+Set this to a comma-separated list of [ntfy tags](https://docs.ntfy.sh/publish/#tags-emojis) to apply to notifications (for example, `eyes` or `eyes,warning`).
+
### Username + Password authentication (optional)
Set this to the username and password for your ntfy.sh server.
diff --git a/docs/using-seerr/notifications/webhook.md b/docs/using-seerr/notifications/webhook.md
index c359c9262f..263058f9ae 100644
--- a/docs/using-seerr/notifications/webhook.md
+++ b/docs/using-seerr/notifications/webhook.md
@@ -53,13 +53,13 @@ Customize the JSON payload to suit your needs. Seerr provides several [template
These variables are for the target recipient of the notification.
-| Variable | Value |
-| ---------------------------------------- | ------------------------------------------------------------- |
-| `{{notifyuser_username}}` | The target notification recipient's username |
-| `{{notifyuser_email}}` | The target notification recipient's email address |
-| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
-| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
-| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
+| Variable | Value |
+| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
+| `{{notifyuser_username}}` | The target notification recipient's username |
+| `{{notifyuser_email}}` | The target notification recipient's email address |
+| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
+| `{{notifyuser_settings_discordIds}}` | The target notification recipient's Discord ID(s) as a JSON array (if set) |
+| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
:::info
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
@@ -113,15 +113,15 @@ The `{{request}}` will be `null` if there is no relevant media object for the no
The following special variables are only included in request-related notifications.
-| Variable | Value |
-| ----------------------------------------- | ----------------------------------------------- |
-| `{{request_id}}` | The request ID |
-| `{{requestedBy_username}}` | The requesting user's username |
-| `{{requestedBy_email}}` | The requesting user's email address |
-| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
-| `{{requestedBy_jellyfinUserId}}` | The requesting user's Jellyfin User ID |
-| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
-| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
+| Variable | Value |
+| ----------------------------------------- | ------------------------------------------------------------------------------ |
+| `{{request_id}}` | The request ID |
+| `{{requestedBy_username}}` | The requesting user's username |
+| `{{requestedBy_email}}` | The requesting user's email address |
+| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
+| `{{requestedBy_jellyfinUserId}}` | The requesting user's Jellyfin User ID |
+| `{{requestedBy_settings_discordIds}}` | The requesting user's Discord ID(s) as a JSON array (if set) |
+| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
@@ -129,14 +129,14 @@ The `{{issue}}` will be `null` if there is no relevant media object for the noti
The following special variables are only included in issue-related notifications.
-| Variable | Value |
-| ---------------------------------------- | ----------------------------------------------- |
-| `{{issue_id}}` | The issue ID |
-| `{{reportedBy_username}}` | The requesting user's username |
-| `{{reportedBy_email}}` | The requesting user's email address |
-| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
-| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
-| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
+| Variable | Value |
+| ---------------------------------------- | ------------------------------------------------------------------------------ |
+| `{{issue_id}}` | The issue ID |
+| `{{reportedBy_username}}` | The requesting user's username |
+| `{{reportedBy_email}}` | The requesting user's email address |
+| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
+| `{{reportedBy_settings_discordIds}}` | The reporting user's Discord ID(s) as a JSON array (if set) |
+| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
@@ -144,11 +144,11 @@ The `{{comment}}` will be `null` if there is no relevant media object for the no
The following special variables are only included in issue comment-related notifications.
-| Variable | Value |
-| ----------------------------------------- | ----------------------------------------------- |
-| `{{comment_message}}` | The comment message |
-| `{{commentedBy_username}}` | The commenting user's username |
-| `{{commentedBy_email}}` | The commenting user's email address |
-| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
-| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
-| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
+| Variable | Value |
+| ----------------------------------------- | ------------------------------------------------------------------------------ |
+| `{{comment_message}}` | The comment message |
+| `{{commentedBy_username}}` | The commenting user's username |
+| `{{commentedBy_email}}` | The commenting user's email address |
+| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
+| `{{commentedBy_settings_discordIds}}` | The commenting user's Discord ID(s) as a JSON array (if set) |
+| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
diff --git a/docs/using-seerr/plex/index.md b/docs/using-seerr/plex/index.md
index e699392657..68b4d1f549 100644
--- a/docs/using-seerr/plex/index.md
+++ b/docs/using-seerr/plex/index.md
@@ -10,7 +10,7 @@ Seerr provides integration features that connect with your Plex media server to
## Available Features
-- [Watchlist Auto Request](./plex/watchlist-auto-request) - Automatically request media from your Plex Watchlist
+- [Watchlist Auto Request](./watchlist-auto-request) - Automatically request media from your Plex Watchlist
- More features coming soon!
## Prerequisites
diff --git a/docs/using-seerr/settings/general.md b/docs/using-seerr/settings/general.md
index 65b337495f..24bf19daa9 100644
--- a/docs/using-seerr/settings/general.md
+++ b/docs/using-seerr/settings/general.md
@@ -71,6 +71,14 @@ When enabled, media that has been blocklisted will not appear on the "Discover"
This setting is **disabled** by default.
+## Hide Requested Media
+
+When enabled, media that has been requested (but not yet available) will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
+
+Requested media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title.
+
+This setting is **disabled** by default.
+
## Allow Partial Series Requests
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
diff --git a/gen-docs/blog/2026-02-28-seerr-security-fix-release.md b/gen-docs/blog/2026-02-28-seerr-security-fix-release.md
index bfe5077ad0..db5d5e01fd 100644
--- a/gen-docs/blog/2026-02-28-seerr-security-fix-release.md
+++ b/gen-docs/blog/2026-02-28-seerr-security-fix-release.md
@@ -2,7 +2,7 @@
title: "Seerr v3.1.0: Critical Security Release"
description: "Seerr v3.1.0 addresses three CVEs, including a high-priority vulnerability affecting Plex-configured instances. Upgrade immediately."
slug: seerr-3-1-0-security-release
-authors: [seerr-team]
+authors: [fallenbagel]
image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg
hide_table_of_contents: false
---
@@ -69,4 +69,4 @@ Many thanks to those making their first contribution to Seerr in this release:
Now that the post-merger feature freeze has ended, the team is resuming active feature development. Stay tuned to our blog for upcoming releases and in-depth looks at what we're building next.
-In the meantime, please upgrade to **v3.1.0** right away, especially if you are using a Plex Media Server configuration. See our [migration guide](https://docs.seerr.dev/migration-guide) if you need help upgrading from Overseerr/Jellyseerr.
\ No newline at end of file
+In the meantime, please upgrade to **v3.1.0** right away, especially if you are using a Plex Media Server configuration. See our [migration guide](https://docs.seerr.dev/migration-guide) if you need help upgrading from Overseerr/Jellyseerr.
diff --git a/gen-docs/blog/2026-06-01/blocklist-region-language.png b/gen-docs/blog/2026-06-01/blocklist-region-language.png
new file mode 100644
index 0000000000..2df2e2ccf6
Binary files /dev/null and b/gen-docs/blog/2026-06-01/blocklist-region-language.png differ
diff --git a/gen-docs/blog/2026-06-01/discord-multiple-ids.png b/gen-docs/blog/2026-06-01/discord-multiple-ids.png
new file mode 100644
index 0000000000..0312266f2f
Binary files /dev/null and b/gen-docs/blog/2026-06-01/discord-multiple-ids.png differ
diff --git a/gen-docs/blog/2026-06-01/seerr-3-2-0-and-3-3-0-release-notes.md b/gen-docs/blog/2026-06-01/seerr-3-2-0-and-3-3-0-release-notes.md
new file mode 100644
index 0000000000..c47971eeb1
--- /dev/null
+++ b/gen-docs/blog/2026-06-01/seerr-3-2-0-and-3-3-0-release-notes.md
@@ -0,0 +1,98 @@
+---
+title: "Seerr v3.2.0 and v3.3.0 Release Notes"
+description: "Seerr v3.2.0 and v3.3.0"
+slug: seerr-3-2-0-and-3-3-0-release-notes
+authors: [gauthier-th]
+image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg
+hide_table_of_contents: false
+---
+
+
+This release note covers both **v3.2.0** and **v3.3.0** releases, which include a host of new features, improvements, and bug fixes to enhance your Seerr experience.
+
+We have been a bit busy recently and forgot to release the notes for **v3.2.0**, so this release note also includes the previous release notes for v3.2.0, which was released in the interim. Better late than never, right?
+
+
+
+Beyond the usual bug fixes and optimizations, here are some of the highlights from these releases:
+
+## What's New?
+
+### Blocklist Upgrades
+
+With the new region and language settings, you can now tailor your blocklist to filter content based on specific regions or languages.
+
+Moreover, you can now blocklist entire collections of media items, allowing you to filter out specific franchises, series, or groups of related content with ease.
+
+
+
+### Expanded Language Support
+
+In this release, we added a long-awaited feature: server-side internationalization (i18n) for all notification agents.
+Notifications now automatically translate at send-time based on the recipient's language, with dedicated locale settings for Discord, Slack, Gotify, and ntfy—including a Discord toggle to match the requesting user's language.
+
+
+
+We are continuously working to make the platform accessible to everyone, everywhere. This update expands our internationalization efforts by officially introducing full language support for **Estonian**, **Luxembourgish**, and **Vietnamese**.
+
+### Notifications & Webhooks
+
+Webhooks are also more powerful with support for custom headers and the inclusion of `imdbid` in payloads.
+
+
+
+Additionally, **ntfy** users can now utilize rich Markdown formatting and custom priority settings.
+
+Discord users can now also include multiple user or role IDs in the `User IDs` field, allowing for more flexible and targeted notifications.
+
+
+
+### Other Enhancements & Quality of Life Improvements
+
+- You can now choose to enable or disable the `monitorNewItems` option for Sonarr in the service settings.
+- We added the ability to set an unlimited time for quota resets, allowing you to add a global request per user.
+- In the user list, you can now sort your users by name, email, role, requests, and more to find the users you are looking for more easily.
+- Filters on the trending page have been added to see only trending movies or TV shows, with the option to filter by the daily or weekly trending lists.
+- Handy IMDb and TMDB links have been added directly on the person detail pages.
+
+We've also officially promoted ourselves and removed the "BETA software" banner from the About Page. We are well past the beta phase and running with the big kids now.
+
+You can find the full list of the new features and bug fixes in the [v3.2.0 release notes](https://github.com/seerr-team/seerr/releases/tag/v3.2.0) and the [v3.3.0 release notes](https://github.com/seerr-team/seerr/releases/tag/v3.3.0).
+
+## New Contributors
+
+Many thanks to those making their first contribution to Seerr in v3.2.0 and v3.3.0:
+
+* [@aslafy-z](https://github.com/aslafy-z)
+* [@leereilly](https://github.com/leereilly)
+* [@jisef](https://github.com/jisef)
+* [@dougrathbone](https://github.com/dougrathbone)
+* [@bobziroll](https://github.com/bobziroll)
+* [@v3DJG6GL](https://github.com/v3DJG6GL)
+* [@Roboroads](https://github.com/Roboroads)
+* [@costajohnt](https://github.com/costajohnt)
+* [@tiagodefendi](https://github.com/tiagodefendi)
+* [@Jyasapara](https://github.com/Jyasapara)
+* [@Sym-jay](https://github.com/Sym-jay)
+* [@bibi0019](https://github.com/bibi0019)
+* [@redondos](https://github.com/redondos)
+* [@bogo22](https://github.com/bogo22)
+* [@jabloink](https://github.com/jabloink)
+* [@YakGravity](https://github.com/YakGravity)
+* [@dj0024javia](https://github.com/dj0024javia)
+* [@Jerra94](https://github.com/Jerra94)
+* [@its-wizza](https://github.com/its-wizza)
+* [@ventiph](https://github.com/ventiph)
+* [@RinZ27](https://github.com/RinZ27)
+* [@aldoeliacim](https://github.com/aldoeliacim)
+* [@danjuv](https://github.com/danjuv)
+* [@kyle-engler](https://github.com/kyle-engler)
+* [@Finchow](https://github.com/Finchow)
+* [@Josh-Archer](https://github.com/Josh-Archer)
+* [@marcinjurczak](https://github.com/marcinjurczak)
+* [@fredrikburmester](https://github.com/fredrikburmester)
+* [@death2all110](https://github.com/death2all110)
+* [@burakemirsezen](https://github.com/burakemirsezen)
+* [@felixschndr](https://github.com/felixschndr)
+* [@haribo-hyung](https://github.com/haribo-hyung)
+* [@defaultdino](https://github.com/defaultdino)
diff --git a/gen-docs/blog/2026-06-01/server-side-i18n.png b/gen-docs/blog/2026-06-01/server-side-i18n.png
new file mode 100644
index 0000000000..5f9e844389
Binary files /dev/null and b/gen-docs/blog/2026-06-01/server-side-i18n.png differ
diff --git a/gen-docs/blog/2026-06-01/webhook-custom-headers.png b/gen-docs/blog/2026-06-01/webhook-custom-headers.png
new file mode 100644
index 0000000000..142c0c79d9
Binary files /dev/null and b/gen-docs/blog/2026-06-01/webhook-custom-headers.png differ
diff --git a/gen-docs/blog/2026-06-03-why-seerr-runs-rootless.md b/gen-docs/blog/2026-06-03-why-seerr-runs-rootless.md
new file mode 100644
index 0000000000..8c6e858158
--- /dev/null
+++ b/gen-docs/blog/2026-06-03-why-seerr-runs-rootless.md
@@ -0,0 +1,84 @@
+---
+title: "Why Seerr Doesn't Support PUID/PGID"
+description: "Seerr runs rootless by design. Here's why we won't be adding PUID/PGID support, and why chown is the right approach."
+slug: why-seerr-doesnt-support-puid-pgid
+authors: [fallenbagel]
+image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg
+hide_table_of_contents: false
+---
+
+A common question we get from users migrating from Overseerr or Jellyseerr is why Seerr doesn't support `PUID`/`PGID` environment variables for setting the user the container runs as. This post explains the reasoning behind that decision and why using chown on the host is the correct approach.
+
+
+
+## What PUID/PGID actually does under the hood
+
+The `PUID`/`PGID` pattern, popularised by images like LinuxServer.io, works by running the container entrypoint **as root**, executing a script that calls `chown`/`chmod` at runtime to reassign file ownership, then using a privilege-dropping tool to switch to the target user before starting the app.
+
+So it's still running `chown`; just inside the container, as root, on every single startup. The critical detail here is that **the container must start as root (UID 0) for this to work at all**. That single requirement undermines the whole security model.
+
+## Running as root in a container is a real risk
+
+Docker containers share the host kernel and they are not VMs. If a process inside a root container escapes the namespace via a kernel vulnerability, a container runtime bug, or a compromised dependency, it lands on your host **as root**.
+
+This is well documented:
+
+- Docker's security documentation explicitly warns against it: https://docs.docker.com/engine/security/#linux-kernel-capabilities
+- The official Docker best practices guide recommends using the `USER` instruction in your Dockerfile to avoid running as root: https://docs.docker.com/build/building/best-practices/#user
+- OWASP's Docker Security Cheat Sheet lists it as Rule #2: https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-2-set-a-user
+
+:::note
+There is one legitimate exception: Docker's [user namespace remapping](https://docs.docker.com/engine/security/userns-remap/) (`userns-remap`), where UID 0 inside the container is mapped to an unprivileged high-number UID on the host via a kernel-level isolation mechanism. However,`userns-remap` is **not enabled by default**, requires explicit host-level configuration, and is not what the `PUID`/`PGID` environment variable pattern does. Without it configured on the host, a container starting as root is simply running as root.
+:::
+
+## How we ship Seerr instead
+
+Seerr runs as the `node` (UID `1000`) user baked into the official Node.js Docker image. This is exactly how the Node.js Docker team documents it in their own examples. Their own official README uses `user: "node"` in the example Docker Compose configuration: https://github.com/nodejs/docker-node/blob/main/README.md.
+
+We also use [pnpm](https://pnpm.io) as our package manager, which takes a stricter and more secure approach to dependency management than npm. Unlike npm, pnpm uses a content-addressable store with hard links and a non-hoisted, symlinked `node_modules` layout that prevents phantom dependencies (packages silently relying on undeclared transitive dependencies). pnpm also ships with hardened install defaults, including integrity verification and lifecycle script controls, that reduce the supply-chain attack surface.
+
+## Understanding Docker's `USER` directive
+
+We often hear users assume that the `USER` directive in a Dockerfile is a strict, unchangeable rule.
+
+In reality, it simply sets the *default* user for the container. It can be overridden at runtime using [`--user` flag in docker cli](https://docs.docker.com/reference/cli/docker/container/run/) or by specifying [`user:` in your docker compose file](https://docs.docker.com/reference/compose-file/services/#user).
+
+However, when you do switch users, it’s important to ensure that any mounted volumes (such as a `/path/to/appdata/config` directory in our case) have the correct permissions for the new user to avoid access issues.
+
+## Why does Seerr default to UID/GID `1000:1000`?
+
+A common question is whether the UID/GID needs to exist on the host machine to run the container. It doesn't; it only needs to exist inside the container. This means you can use any UID/GID, even one that doesn't correspond to a user on your host system.
+
+However, if a security vulnerability allows an attacker to escape the container, they inherit the permissions of that UID/GID on the host, if it exists. This is exactly the scenario we're avoiding by not running as root. If Seerr (or any of its dependencies) ran as root, as Overseerr and Jellyseerr did, a container escape would hand an attacker root on your host. The same applies to the `PUID`/`PGID` pattern: since it requires the container to start as root before dropping privileges, a vulnerability triggered before that drop would have the same effect. To mitigate this, Docker offers [user namespace remapping](https://docs.docker.com/engine/security/userns-remap/), which lets you map container UIDs to different, non-conflicting UIDs on the host for stronger isolation.
+
+You might also wonder why `1000:1000` specifically. While `1000` is often the first non-root user created on many Linux systems, our reason is simpler: it's the default user created in the [base Node.js Docker image](https://github.com/nodejs/docker-node/blob/c94b4249a1a1bfb2ebb05fac9012cd34fecc2db7/22/alpine3.23/Dockerfile#L5).
+
+As for why we don't go into more depth on this in our docs: we focus on the basics and the most common use cases for Seerr itself. Docker's permission model, including how the `USER` directive works, is already well documented by Docker. Our goal is to get you up and running smoothly, while encouraging you to explore the tools you're using.
+
+## So what do you actually need to do?
+
+If you're mounting a data directory into the Seerr container, that directory needs to be owned by UID `1000` (the `node` user) on the host. You do this once:
+
+```bash
+chown -R 1000:1000 /your/seerr/data
+```
+
+That's it. A common misconception we've encountered from users unfamiliar with this setup is that this is a "workaround" or a "hack", but it isn't. It is basic Linux file permissions and it works as intended. If you're unfamiliar with how Linux file ownership works, DigitalOcean has a solid primer: https://www.digitalocean.com/community/tutorials/how-to-set-permissions-linux.
+
+Docker's own documentation recommends this exact approach for bind mount permissions: https://docs.docker.com/engine/storage/bind-mounts/.
+
+:::note
+Unraid users may prefer to match the container user to their existing share permissions rather than chowning their data directory. Our [Unraid documentation](/getting-started/third-parties/unraid) covers both approaches. Note that third-party platform support is community-maintained and not officially supported by the Seerr team.
+:::
+
+## Why we won't add PUID/PGID support
+
+Adding `PUID`/`PGID` support would require us to deliberately ship an image that starts as root. In a project with hundreds of npm dependencies, a single compromised package in a supply chain attack could, under that setup, gain root-level access to your server. The convenience of skipping one `chown` command does not come close to justifying that tradeoff.
+
+:::tip
+For additional hardening, you can mount the container filesystem as [read-only](https://docs.docker.com/reference/compose-file/services/#read_only) and grant write access only to your data directory. Since all runtime writes go through `CONFIG_DIRECTORY`, nothing outside that volume needs to be writable.
+:::
+
+If you need the PUID/PGID pattern, third-party images that implement it already exist. But it is not something we will maintain here, because it would mean knowingly reintroducing a security regression we deliberately moved away from.
+
+**Seerr's container setup is more secure than what Overseerr and Jellyseerr shipped, and we intend to keep it that way.**
\ No newline at end of file
diff --git a/gen-docs/docusaurus.config.ts b/gen-docs/docusaurus.config.ts
index d94c4939ba..d2a6c07155 100644
--- a/gen-docs/docusaurus.config.ts
+++ b/gen-docs/docusaurus.config.ts
@@ -10,7 +10,13 @@ const config: Config = {
url: 'https://docs.seerr.dev',
baseUrl: '/',
- trailingSlash: false,
+ trailingSlash: true,
+
+ future: {
+ faster: {
+ swcJsMinimizer: true,
+ },
+ },
organizationName: 'seerr-team',
projectName: 'seerr',
@@ -39,6 +45,19 @@ const config: Config = {
path: '../docs',
editUrl: 'https://github.com/seerr-team/seerr/edit/develop/docs/',
docItemComponent: '@theme/ApiItem',
+ async sidebarItemsGenerator({
+ defaultSidebarItemsGenerator,
+ ...args
+ }) {
+ const items = await defaultSidebarItemsGenerator(args);
+ return items.filter(
+ (item) =>
+ !(
+ item.type === 'category' &&
+ item.label?.toLowerCase() === 'api'
+ )
+ );
+ },
},
pages: false,
theme: {
@@ -97,6 +116,11 @@ const config: Config = {
src: 'img/logo_full.svg',
},
items: [
+ {
+ to: '/api/seerr-api',
+ label: 'REST API',
+ position: 'right',
+ },
{
to: 'blog',
label: 'Blog',
@@ -124,6 +148,10 @@ const config: Config = {
label: 'Documentation',
to: '/',
},
+ {
+ label: 'REST API',
+ to: '/api/seerr-api',
+ },
],
},
{
diff --git a/gen-docs/package.json b/gen-docs/package.json
index a2f010d8d0..6857e54ac6 100644
--- a/gen-docs/package.json
+++ b/gen-docs/package.json
@@ -7,7 +7,7 @@
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
- "gen-api-docs": "docusaurus gen-api-docs",
+ "gen-api-docs": "docusaurus gen-api-docs all",
"clean-api-docs": "node ./scripts/clean-api-docs.cjs",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
@@ -19,12 +19,13 @@
},
"dependencies": {
"@docusaurus/core": "3.10.1",
+ "@docusaurus/faster": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@easyops-cn/docusaurus-search-local": "^0.55.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.1",
- "docusaurus-plugin-openapi-docs": "^5.0.1",
- "docusaurus-theme-openapi-docs": "^5.0.1",
+ "docusaurus-plugin-openapi-docs": "5.0.2",
+ "docusaurus-theme-openapi-docs": "5.0.2",
"prism-react-renderer": "^2.4.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
diff --git a/gen-docs/pnpm-lock.yaml b/gen-docs/pnpm-lock.yaml
index 423b2b6d74..610bcf106a 100644
--- a/gen-docs/pnpm-lock.yaml
+++ b/gen-docs/pnpm-lock.yaml
@@ -10,13 +10,16 @@ importers:
dependencies:
'@docusaurus/core':
specifier: 3.10.1
- version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ version: 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/faster':
+ specifier: ^3.10.1
+ version: 3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@docusaurus/preset-classic':
specifier: 3.10.1
- version: 3.10.1(@algolia/client-search@5.39.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)
+ version: 3.10.1(@algolia/client-search@5.39.0)(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)
'@easyops-cn/docusaurus-search-local':
specifier: ^0.55.1
- version: 0.55.1(@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ version: 0.55.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@mdx-js/react':
specifier: ^3.0.0
version: 3.1.1(@types/react@19.2.14)(react@18.3.1)
@@ -24,11 +27,11 @@ importers:
specifier: ^2.1.1
version: 2.1.1
docusaurus-plugin-openapi-docs:
- specifier: ^5.0.1
- version: 5.0.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1)
+ specifier: 5.0.2
+ version: 5.0.2(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1)
docusaurus-theme-openapi-docs:
- specifier: ^5.0.1
- version: 5.0.1(@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@19.2.14)(docusaurus-plugin-openapi-docs@5.0.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1))(docusaurus-plugin-sass@0.2.6(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(sass@1.99.0)(webpack@5.101.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@5.0.1)(webpack@5.101.3)
+ specifier: 5.0.2
+ version: 5.0.2(e20b839675fe7ee566ace2729a870ef0)
prism-react-renderer:
specifier: ^2.4.1
version: 2.4.1(react@18.3.1)
@@ -44,13 +47,13 @@ importers:
devDependencies:
'@docusaurus/module-type-aliases':
specifier: 3.10.1
- version: 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@docusaurus/tsconfig':
specifier: 3.10.1
version: 3.10.1
'@docusaurus/types':
specifier: 3.10.1
- version: 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
typescript:
specifier: ~6.0.3
version: 6.0.3
@@ -1054,6 +1057,12 @@ packages:
resolution: {integrity: sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==}
engines: {node: '>=20.0'}
+ '@docusaurus/faster@3.10.1':
+ resolution: {integrity: sha512-XTZhE5C1gZ/DaYYMlSk02dwP5vhpQON5QHVz1s3892mSESAywgWanURpXEDAvt4GvGuq7s+XP8rTWHZvfaJmdQ==}
+ engines: {node: '>=20.0'}
+ peerDependencies:
+ '@docusaurus/types': '*'
+
'@docusaurus/logger@3.10.1':
resolution: {integrity: sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==}
engines: {node: '>=20.0'}
@@ -1317,9 +1326,30 @@ packages:
'@types/react': '>=16'
react: '>=16'
+ '@module-federation/error-codes@0.22.0':
+ resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==}
+
+ '@module-federation/runtime-core@0.22.0':
+ resolution: {integrity: sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==}
+
+ '@module-federation/runtime-tools@0.22.0':
+ resolution: {integrity: sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==}
+
+ '@module-federation/runtime@0.22.0':
+ resolution: {integrity: sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==}
+
+ '@module-federation/sdk@0.22.0':
+ resolution: {integrity: sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==}
+
+ '@module-federation/webpack-bundler-runtime@0.22.0':
+ resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==}
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
+ '@napi-rs/wasm-runtime@1.0.7':
+ resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
+
'@node-rs/jieba-android-arm-eabi@1.10.4':
resolution: {integrity: sha512-MhyvW5N3Fwcp385d0rxbCWH42kqDBatQTyP8XbnYbju2+0BO/eTeCCLYj7Agws4pwxn2LtdldXRSKavT7WdzNA==}
engines: {node: '>= 10'}
@@ -1540,6 +1570,70 @@ packages:
react-redux:
optional: true
+ '@rspack/binding-darwin-arm64@1.7.11':
+ resolution: {integrity: sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rspack/binding-darwin-x64@1.7.11':
+ resolution: {integrity: sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rspack/binding-linux-arm64-gnu@1.7.11':
+ resolution: {integrity: sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rspack/binding-linux-arm64-musl@1.7.11':
+ resolution: {integrity: sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rspack/binding-linux-x64-gnu@1.7.11':
+ resolution: {integrity: sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rspack/binding-linux-x64-musl@1.7.11':
+ resolution: {integrity: sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rspack/binding-wasm32-wasi@1.7.11':
+ resolution: {integrity: sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==}
+ cpu: [wasm32]
+
+ '@rspack/binding-win32-arm64-msvc@1.7.11':
+ resolution: {integrity: sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rspack/binding-win32-ia32-msvc@1.7.11':
+ resolution: {integrity: sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rspack/binding-win32-x64-msvc@1.7.11':
+ resolution: {integrity: sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rspack/binding@1.7.11':
+ resolution: {integrity: sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==}
+
+ '@rspack/core@1.7.11':
+ resolution: {integrity: sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==}
+ engines: {node: '>=18.12.0'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.1'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@rspack/lite-tapable@1.1.0':
+ resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==}
+
'@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -1653,6 +1747,169 @@ packages:
resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==}
engines: {node: '>=14'}
+ '@swc/core-darwin-arm64@1.15.33':
+ resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.15.33':
+ resolution: {integrity: sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.15.33':
+ resolution: {integrity: sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.15.33':
+ resolution: {integrity: sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/core-linux-arm64-musl@1.15.33':
+ resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/core-linux-ppc64-gnu@1.15.33':
+ resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@swc/core-linux-s390x-gnu@1.15.33':
+ resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@swc/core-linux-x64-gnu@1.15.33':
+ resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/core-linux-x64-musl@1.15.33':
+ resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/core-win32-arm64-msvc@1.15.33':
+ resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.15.33':
+ resolution: {integrity: sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.15.33':
+ resolution: {integrity: sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.15.33':
+ resolution: {integrity: sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/html-darwin-arm64@1.15.33':
+ resolution: {integrity: sha512-zyO6uMBfLyCh55wundAxKX+8P/f98ecuyir4VX6nTmn6y7x37ndB8f01LUrd9Tiq6eEAvDXLiqEUvuGjEc7Pmg==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/html-darwin-x64@1.15.33':
+ resolution: {integrity: sha512-MaGunsY/J5l7Rb5OmoztEWh+ikooydT7nWkjiDovj7UfkB9HLk5sLr9O7ZdNGJ2u9dD6FX89SzMdA0Psm9NJrQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/html-linux-arm-gnueabihf@1.15.33':
+ resolution: {integrity: sha512-CrbUDjVl6/hQ1C5KPMiK4vxk/eOMjxkVELqwnOxsZ+aFVTv3L3YrGMaJ5H47vvIihkPhqiSOUPmMEFqxvqKmXg==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/html-linux-arm64-gnu@1.15.33':
+ resolution: {integrity: sha512-7tZ0IgmUslI9Extu/TpxJS0GjJoDx0j9zeq2cIidPdM/njSBpyRB7n4B292Q5WFVh7PcZl7WXqqqMczibQ27aA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/html-linux-arm64-musl@1.15.33':
+ resolution: {integrity: sha512-gYi2ainYZV2z+jwjp9UKuPVOf3c5q+NkH3QRDjqDrIPLagqDsYNjobi8p5oajGcPGFLNTcVw08VTcubJGChReA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/html-linux-ppc64-gnu@1.15.33':
+ resolution: {integrity: sha512-6CfzyVQSdD8ezFdxFve4J/b6qTgXIwYFWEvSdaJvXSgwTy976uUV5Ff1LOF86mt2zWMhZJX9DqmkGyIhepbyWw==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@swc/html-linux-s390x-gnu@1.15.33':
+ resolution: {integrity: sha512-Msx1eniw95lhMHUSe3D5FXweKHtkHtzJLsHJDj920uL4Dm7UHqzwaCuZdCmzbkHnO96YjjQvAm266djg8wupmQ==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@swc/html-linux-x64-gnu@1.15.33':
+ resolution: {integrity: sha512-JDNb4Uq+7g+23QuOtwWnP0/EqztWIHFFdQdeBIS5zx83YBG2dYRMdPAjnHJWh2YRZxdepd8q6S9MUIxpSrouAg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/html-linux-x64-musl@1.15.33':
+ resolution: {integrity: sha512-NSpZdbz4dj0pu1A0Z9l68Bll5HAzEMtBAeMe6jc4GEVfpIw6eeafQHm2/yMUEh09tgl8t9LzM9DycfdTZDjM4g==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/html-win32-arm64-msvc@1.15.33':
+ resolution: {integrity: sha512-w7iho3/zS3lCDqgUZMDLMBO0ElX7j+KgvMb8BOrKqLDOSTDDj3lY/BClNJ7vBpAliI2kPQs/mUikdZyzi4MBjQ==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/html-win32-ia32-msvc@1.15.33':
+ resolution: {integrity: sha512-6hJ2pBweSfZ38trYHXmzTBDpRNvqJgFl2PkIWdy4IXbV/Fv0v9Dqe0t9Gi2ZVEBpgI7PD6pF42AT4HmrNTVFyQ==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/html-win32-x64-msvc@1.15.33':
+ resolution: {integrity: sha512-eaY/vNE7rkPKluJYjhOiQOA1tto5VbJOoD1C1xFTBmr9t7WsqYUfbQhYQy5A26/z83NNgtDwELM85rkMB+/vWA==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/html@1.15.33':
+ resolution: {integrity: sha512-PZIfmj5zYpAJ2eMptf0My2q9Bl8bkraW28+FD1pRnxOiYMrKrP5vL2tB2PdxMRjS0ziLFVM5HEuGFw8PxEDOaw==}
+ engines: {node: '>=14'}
+
+ '@swc/types@0.1.26':
+ resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
+
'@szmarczak/http-timer@5.0.1':
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
@@ -2619,8 +2876,8 @@ packages:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'}
- docusaurus-plugin-openapi-docs@5.0.1:
- resolution: {integrity: sha512-OVfoDovRdiS78DQYWmr2BjuOF2A6kVmJ43mgkQaAEZxASyHbUft4zUIhvfa7gqema6KNL9pVKejDievZdZ3wGQ==}
+ docusaurus-plugin-openapi-docs@5.0.2:
+ resolution: {integrity: sha512-WCC2m6PpylXZfNga+ScelTG0a7jUGtbB9+AmbR9lUj93FPryTs8VHTMJ3fKtO0senJTWgOU3MDvZw0v+mE3ztA==}
engines: {node: '>=14'}
peerDependencies:
'@docusaurus/plugin-content-docs': ^3.10.0
@@ -2634,8 +2891,8 @@ packages:
'@docusaurus/core': ^2.0.0-beta || ^3.0.0-alpha
sass: ^1.30.0
- docusaurus-theme-openapi-docs@5.0.1:
- resolution: {integrity: sha512-bVeb7hOqog9LKVrJzYXdNJ7/0N22lk0VE22QK+naAn5GuAvYo41JmpXW9hqLIPkEp2UbexTHoPW9SYVdUsyvvw==}
+ docusaurus-theme-openapi-docs@5.0.2:
+ resolution: {integrity: sha512-BD6WhbunR6kXqtoUUDlhxO4HlCNM2nYENGr/TbiTEknkgXYKQz+FEIhY4Hyz5GSLpuhPih0CDuNl7Xkfpcz0Yw==}
engines: {node: '>=14'}
peerDependencies:
'@docusaurus/theme-common': ^3.10.0
@@ -3554,6 +3811,76 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -3895,9 +4222,6 @@ packages:
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
- minimatch@3.1.2:
- resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
-
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
@@ -5334,6 +5658,12 @@ packages:
resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==}
hasBin: true
+ swc-loader@0.2.7:
+ resolution: {integrity: sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==}
+ peerDependencies:
+ '@swc/core': ^1.2.147
+ webpack: '>=2'
+
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'}
@@ -6983,7 +7313,7 @@ snapshots:
- '@algolia/client-search'
- algoliasearch
- '@docusaurus/babel@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/babel@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
@@ -6995,7 +7325,7 @@ snapshots:
'@babel/runtime': 7.29.2
'@babel/traverse': 7.28.4
'@docusaurus/logger': 3.10.1
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
babel-plugin-dynamic-import-node: 2.3.3
fs-extra: 11.3.2
tslib: 2.8.1
@@ -7008,32 +7338,34 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/bundler@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/bundler@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
'@babel/core': 7.28.4
- '@docusaurus/babel': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/babel': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@docusaurus/cssnano-preset': 3.10.1
'@docusaurus/logger': 3.10.1
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- babel-loader: 9.2.1(@babel/core@7.28.4)(webpack@5.101.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ babel-loader: 9.2.1(@babel/core@7.28.4)(webpack@5.101.3(@swc/core@1.15.33))
clean-css: 5.3.3
- copy-webpack-plugin: 11.0.0(webpack@5.101.3)
- css-loader: 6.11.0(webpack@5.101.3)
- css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.101.3)
+ copy-webpack-plugin: 11.0.0(webpack@5.101.3(@swc/core@1.15.33))
+ css-loader: 6.11.0(@rspack/core@1.7.11)(webpack@5.101.3(@swc/core@1.15.33))
+ css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.101.3(@swc/core@1.15.33))
cssnano: 6.1.2(postcss@8.5.9)
- file-loader: 6.2.0(webpack@5.101.3)
+ file-loader: 6.2.0(webpack@5.101.3(@swc/core@1.15.33))
html-minifier-terser: 7.2.0
- mini-css-extract-plugin: 2.9.4(webpack@5.101.3)
- null-loader: 4.0.1(webpack@5.101.3)
+ mini-css-extract-plugin: 2.9.4(webpack@5.101.3(@swc/core@1.15.33))
+ null-loader: 4.0.1(webpack@5.101.3(@swc/core@1.15.33))
postcss: 8.5.9
- postcss-loader: 7.3.4(postcss@8.5.9)(typescript@6.0.3)(webpack@5.101.3)
+ postcss-loader: 7.3.4(postcss@8.5.9)(typescript@6.0.3)(webpack@5.101.3(@swc/core@1.15.33))
postcss-preset-env: 10.4.0(postcss@8.5.9)
- terser-webpack-plugin: 5.3.14(webpack@5.101.3)
+ terser-webpack-plugin: 5.3.14(@swc/core@1.15.33)(webpack@5.101.3(@swc/core@1.15.33))
tslib: 2.8.1
- url-loader: 4.1.1(file-loader@6.2.0(webpack@5.101.3))(webpack@5.101.3)
- webpack: 5.101.3
- webpackbar: 7.0.0(webpack@5.101.3)
+ url-loader: 4.1.1(file-loader@6.2.0(webpack@5.101.3(@swc/core@1.15.33)))(webpack@5.101.3(@swc/core@1.15.33))
+ webpack: 5.101.3(@swc/core@1.15.33)
+ webpackbar: 7.0.0(@rspack/core@1.7.11)(webpack@5.101.3(@swc/core@1.15.33))
+ optionalDependencies:
+ '@docusaurus/faster': 3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
transitivePeerDependencies:
- '@parcel/css'
- '@rspack/core'
@@ -7049,15 +7381,15 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/core@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/babel': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/bundler': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/babel': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/bundler': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@docusaurus/logger': 3.10.1
- '@docusaurus/mdx-loader': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1)
boxen: 6.2.1
chalk: 4.1.2
@@ -7073,7 +7405,7 @@ snapshots:
execa: 5.1.1
fs-extra: 11.3.2
html-tags: 3.3.1
- html-webpack-plugin: 5.6.4(webpack@5.101.3)
+ html-webpack-plugin: 5.6.4(@rspack/core@1.7.11)(webpack@5.101.3(@swc/core@1.15.33))
leven: 3.1.0
lodash: 4.17.23
open: 8.4.2
@@ -7083,7 +7415,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)'
react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)'
- react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.101.3)
+ react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.101.3(@swc/core@1.15.33))
react-router: 5.3.4(react@18.3.1)
react-router-config: 5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1)
react-router-dom: 5.3.4(react@18.3.1)
@@ -7092,10 +7424,12 @@ snapshots:
tinypool: 1.1.1
tslib: 2.8.1
update-notifier: 6.0.2
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
webpack-bundle-analyzer: 4.10.2
- webpack-dev-server: 5.2.2(debug@4.4.3)(webpack@5.101.3)
+ webpack-dev-server: 5.2.2(debug@4.4.3)(webpack@5.101.3(@swc/core@1.15.33))
webpack-merge: 6.0.1
+ optionalDependencies:
+ '@docusaurus/faster': 3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
transitivePeerDependencies:
- '@parcel/css'
- '@rspack/core'
@@ -7119,21 +7453,39 @@ snapshots:
postcss-sort-media-queries: 5.2.0(postcss@8.5.9)
tslib: 2.8.1
+ '@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
+ dependencies:
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@rspack/core': 1.7.11
+ '@swc/core': 1.15.33
+ '@swc/html': 1.15.33
+ browserslist: 4.26.2
+ lightningcss: 1.32.0
+ semver: 7.7.2
+ swc-loader: 0.2.7(@swc/core@1.15.33)(webpack@5.101.3(@swc/core@1.15.33))
+ tslib: 2.8.1
+ webpack: 5.101.3(@swc/core@1.15.33)
+ transitivePeerDependencies:
+ - '@swc/helpers'
+ - esbuild
+ - uglify-js
+ - webpack-cli
+
'@docusaurus/logger@3.10.1':
dependencies:
chalk: 4.1.2
tslib: 2.8.1
- '@docusaurus/mdx-loader@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/mdx-loader@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@docusaurus/logger': 3.10.1
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mdx-js/mdx': 3.1.1
'@slorber/remark-comment': 1.0.0
escape-html: 1.0.3
estree-util-value-to-estree: 3.4.0
- file-loader: 6.2.0(webpack@5.101.3)
+ file-loader: 6.2.0(webpack@5.101.3(@swc/core@1.15.33))
fs-extra: 11.3.2
image-size: 2.0.2
mdast-util-mdx: 3.0.0
@@ -7149,9 +7501,9 @@ snapshots:
tslib: 2.8.1
unified: 11.0.5
unist-util-visit: 5.0.0
- url-loader: 4.1.1(file-loader@6.2.0(webpack@5.101.3))(webpack@5.101.3)
+ url-loader: 4.1.1(file-loader@6.2.0(webpack@5.101.3(@swc/core@1.15.33)))(webpack@5.101.3(@swc/core@1.15.33))
vfile: 6.0.3
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- '@swc/core'
- esbuild
@@ -7159,9 +7511,9 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/module-type-aliases@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/module-type-aliases@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/history': 4.7.11
'@types/react': 19.2.14
'@types/react-router-config': 5.0.11
@@ -7177,17 +7529,17 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/plugin-content-blog@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-content-blog@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@docusaurus/logger': 3.10.1
- '@docusaurus/mdx-loader': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
cheerio: 1.0.0-rc.12
combine-promises: 1.2.0
feed: 4.2.2
@@ -7200,7 +7552,7 @@ snapshots:
tslib: 2.8.1
unist-util-visit: 5.0.0
utility-types: 3.11.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- '@docusaurus/faster'
- '@mdx-js/react'
@@ -7219,17 +7571,17 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@docusaurus/logger': 3.10.1
- '@docusaurus/mdx-loader': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/module-type-aliases': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/module-type-aliases': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/react-router-config': 5.0.11
combine-promises: 1.2.0
fs-extra: 11.3.2
@@ -7240,7 +7592,7 @@ snapshots:
schema-dts: 1.1.5
tslib: 2.8.1
utility-types: 3.11.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- '@docusaurus/faster'
- '@mdx-js/react'
@@ -7259,18 +7611,18 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-content-pages@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-content-pages@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/mdx-loader': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
fs-extra: 11.3.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- '@docusaurus/faster'
- '@mdx-js/react'
@@ -7289,12 +7641,12 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-css-cascade-layers@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-css-cascade-layers@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tslib: 2.8.1
transitivePeerDependencies:
- '@docusaurus/faster'
@@ -7316,11 +7668,11 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-debug@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-debug@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
fs-extra: 11.3.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -7344,11 +7696,11 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-google-analytics@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-google-analytics@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
@@ -7370,11 +7722,11 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-google-gtag@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-google-gtag@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/gtag.js': 0.0.20
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -7397,11 +7749,11 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-google-tag-manager@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-google-tag-manager@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
@@ -7423,14 +7775,14 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-sitemap@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-sitemap@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@docusaurus/logger': 3.10.1
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
fs-extra: 11.3.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -7454,18 +7806,18 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/plugin-svgr@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/plugin-svgr@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@svgr/core': 8.1.0(typescript@6.0.3)
'@svgr/webpack': 8.1.0(typescript@6.0.3)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- '@docusaurus/faster'
- '@mdx-js/react'
@@ -7484,23 +7836,23 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/preset-classic@3.10.1(@algolia/client-search@5.39.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)':
- dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-css-cascade-layers': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-debug': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-google-analytics': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-google-gtag': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-google-tag-manager': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-sitemap': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-svgr': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/theme-classic': 3.10.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/theme-search-algolia': 3.10.1(@algolia/client-search@5.39.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/preset-classic@3.10.1(@algolia/client-search@5.39.0)(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)':
+ dependencies:
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-content-pages': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-css-cascade-layers': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-debug': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-google-analytics': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-google-gtag': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-google-tag-manager': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-sitemap': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-svgr': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/theme-classic': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@rspack/core@1.7.11)(@swc/core@1.15.33)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/theme-search-algolia': 3.10.1(@algolia/client-search@5.39.0)(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
@@ -7529,21 +7881,21 @@ snapshots:
'@types/react': 19.2.14
react: 18.3.1
- '@docusaurus/theme-classic@3.10.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@docusaurus/theme-classic@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@rspack/core@1.7.11)(@swc/core@1.15.33)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@docusaurus/logger': 3.10.1
- '@docusaurus/mdx-loader': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/module-type-aliases': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/module-type-aliases': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/plugin-content-pages': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@docusaurus/theme-translations': 3.10.1
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1)
clsx: 2.1.1
copy-text-to-clipboard: 3.2.2
@@ -7577,13 +7929,13 @@ snapshots:
- utf-8-validate
- webpack-cli
- '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
- '@docusaurus/mdx-loader': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/module-type-aliases': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/module-type-aliases': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/history': 4.7.11
'@types/react': 19.2.14
'@types/react-router-config': 5.0.11
@@ -7601,17 +7953,17 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/theme-search-algolia@3.10.1(@algolia/client-search@5.39.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)':
+ '@docusaurus/theme-search-algolia@3.10.1(@algolia/client-search@5.39.0)(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@6.0.3)':
dependencies:
'@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.39.0)(algoliasearch@5.39.0)(search-insights@2.17.3)
'@docsearch/react': 4.6.3(@algolia/client-search@5.39.0)(@types/react@19.2.14)(algoliasearch@5.39.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
'@docusaurus/logger': 3.10.1
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@docusaurus/theme-translations': 3.10.1
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
algoliasearch: 5.39.0
algoliasearch-helper: 3.26.0(algoliasearch@5.39.0)
clsx: 2.1.1
@@ -7650,7 +8002,7 @@ snapshots:
'@docusaurus/tsconfig@3.10.1': {}
- '@docusaurus/types@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@mdx-js/mdx': 3.1.1
'@types/history': 4.7.11
@@ -7662,7 +8014,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)'
utility-types: 3.11.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
webpack-merge: 5.10.0
transitivePeerDependencies:
- '@swc/core'
@@ -7671,9 +8023,9 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/utils-common@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/utils-common@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tslib: 2.8.1
transitivePeerDependencies:
- '@swc/core'
@@ -7684,11 +8036,11 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/utils-validation@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/utils-validation@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@docusaurus/logger': 3.10.1
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
fs-extra: 11.3.2
joi: 17.13.3
js-yaml: 4.1.1
@@ -7703,14 +8055,14 @@ snapshots:
- uglify-js
- webpack-cli
- '@docusaurus/utils@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@docusaurus/utils@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@docusaurus/logger': 3.10.1
- '@docusaurus/types': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/types': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
escape-string-regexp: 4.0.0
execa: 5.1.1
- file-loader: 6.2.0(webpack@5.101.3)
+ file-loader: 6.2.0(webpack@5.101.3(@swc/core@1.15.33))
fs-extra: 11.3.2
github-slugger: 1.5.0
globby: 11.1.0
@@ -7723,9 +8075,9 @@ snapshots:
prompts: 2.4.2
resolve-pathname: 3.0.0
tslib: 2.8.1
- url-loader: 4.1.1(file-loader@6.2.0(webpack@5.101.3))(webpack@5.101.3)
+ url-loader: 4.1.1(file-loader@6.2.0(webpack@5.101.3(@swc/core@1.15.33)))(webpack@5.101.3(@swc/core@1.15.33))
utility-types: 3.11.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- '@swc/core'
- esbuild
@@ -7740,14 +8092,14 @@ snapshots:
cssesc: 3.0.0
immediate: 3.3.0
- '@easyops-cn/docusaurus-search-local@0.55.1(@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
+ '@easyops-cn/docusaurus-search-local@0.55.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)':
dependencies:
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@docusaurus/theme-translations': 3.10.1
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-common': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-common': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@easyops-cn/autocomplete.js': 0.38.1
'@node-rs/jieba': 1.10.4
cheerio: 1.1.2
@@ -7921,6 +8273,31 @@ snapshots:
'@types/react': 19.2.14
react: 18.3.1
+ '@module-federation/error-codes@0.22.0': {}
+
+ '@module-federation/runtime-core@0.22.0':
+ dependencies:
+ '@module-federation/error-codes': 0.22.0
+ '@module-federation/sdk': 0.22.0
+
+ '@module-federation/runtime-tools@0.22.0':
+ dependencies:
+ '@module-federation/runtime': 0.22.0
+ '@module-federation/webpack-bundler-runtime': 0.22.0
+
+ '@module-federation/runtime@0.22.0':
+ dependencies:
+ '@module-federation/error-codes': 0.22.0
+ '@module-federation/runtime-core': 0.22.0
+ '@module-federation/sdk': 0.22.0
+
+ '@module-federation/sdk@0.22.0': {}
+
+ '@module-federation/webpack-bundler-runtime@0.22.0':
+ dependencies:
+ '@module-federation/runtime': 0.22.0
+ '@module-federation/sdk': 0.22.0
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.5.0
@@ -7928,6 +8305,13 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
+ '@napi-rs/wasm-runtime@1.0.7':
+ dependencies:
+ '@emnapi/core': 1.5.0
+ '@emnapi/runtime': 1.5.0
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
'@node-rs/jieba-android-arm-eabi@1.10.4':
optional: true
@@ -8119,6 +8503,59 @@ snapshots:
react: 18.3.1
react-redux: 9.2.0(@types/react@19.2.14)(react@18.3.1)(redux@5.0.1)
+ '@rspack/binding-darwin-arm64@1.7.11':
+ optional: true
+
+ '@rspack/binding-darwin-x64@1.7.11':
+ optional: true
+
+ '@rspack/binding-linux-arm64-gnu@1.7.11':
+ optional: true
+
+ '@rspack/binding-linux-arm64-musl@1.7.11':
+ optional: true
+
+ '@rspack/binding-linux-x64-gnu@1.7.11':
+ optional: true
+
+ '@rspack/binding-linux-x64-musl@1.7.11':
+ optional: true
+
+ '@rspack/binding-wasm32-wasi@1.7.11':
+ dependencies:
+ '@napi-rs/wasm-runtime': 1.0.7
+ optional: true
+
+ '@rspack/binding-win32-arm64-msvc@1.7.11':
+ optional: true
+
+ '@rspack/binding-win32-ia32-msvc@1.7.11':
+ optional: true
+
+ '@rspack/binding-win32-x64-msvc@1.7.11':
+ optional: true
+
+ '@rspack/binding@1.7.11':
+ optionalDependencies:
+ '@rspack/binding-darwin-arm64': 1.7.11
+ '@rspack/binding-darwin-x64': 1.7.11
+ '@rspack/binding-linux-arm64-gnu': 1.7.11
+ '@rspack/binding-linux-arm64-musl': 1.7.11
+ '@rspack/binding-linux-x64-gnu': 1.7.11
+ '@rspack/binding-linux-x64-musl': 1.7.11
+ '@rspack/binding-wasm32-wasi': 1.7.11
+ '@rspack/binding-win32-arm64-msvc': 1.7.11
+ '@rspack/binding-win32-ia32-msvc': 1.7.11
+ '@rspack/binding-win32-x64-msvc': 1.7.11
+
+ '@rspack/core@1.7.11':
+ dependencies:
+ '@module-federation/runtime-tools': 0.22.0
+ '@rspack/binding': 1.7.11
+ '@rspack/lite-tapable': 1.1.0
+
+ '@rspack/lite-tapable@1.1.0': {}
+
'@sideway/address@4.1.5':
dependencies:
'@hapi/hoek': 9.3.0
@@ -8246,6 +8683,119 @@ snapshots:
- supports-color
- typescript
+ '@swc/core-darwin-arm64@1.15.33':
+ optional: true
+
+ '@swc/core-darwin-x64@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.15.33':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.15.33':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.15.33':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.15.33':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.15.33':
+ optional: true
+
+ '@swc/core@1.15.33':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.26
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.15.33
+ '@swc/core-darwin-x64': 1.15.33
+ '@swc/core-linux-arm-gnueabihf': 1.15.33
+ '@swc/core-linux-arm64-gnu': 1.15.33
+ '@swc/core-linux-arm64-musl': 1.15.33
+ '@swc/core-linux-ppc64-gnu': 1.15.33
+ '@swc/core-linux-s390x-gnu': 1.15.33
+ '@swc/core-linux-x64-gnu': 1.15.33
+ '@swc/core-linux-x64-musl': 1.15.33
+ '@swc/core-win32-arm64-msvc': 1.15.33
+ '@swc/core-win32-ia32-msvc': 1.15.33
+ '@swc/core-win32-x64-msvc': 1.15.33
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/html-darwin-arm64@1.15.33':
+ optional: true
+
+ '@swc/html-darwin-x64@1.15.33':
+ optional: true
+
+ '@swc/html-linux-arm-gnueabihf@1.15.33':
+ optional: true
+
+ '@swc/html-linux-arm64-gnu@1.15.33':
+ optional: true
+
+ '@swc/html-linux-arm64-musl@1.15.33':
+ optional: true
+
+ '@swc/html-linux-ppc64-gnu@1.15.33':
+ optional: true
+
+ '@swc/html-linux-s390x-gnu@1.15.33':
+ optional: true
+
+ '@swc/html-linux-x64-gnu@1.15.33':
+ optional: true
+
+ '@swc/html-linux-x64-musl@1.15.33':
+ optional: true
+
+ '@swc/html-win32-arm64-msvc@1.15.33':
+ optional: true
+
+ '@swc/html-win32-ia32-msvc@1.15.33':
+ optional: true
+
+ '@swc/html-win32-x64-msvc@1.15.33':
+ optional: true
+
+ '@swc/html@1.15.33':
+ dependencies:
+ '@swc/counter': 0.1.3
+ optionalDependencies:
+ '@swc/html-darwin-arm64': 1.15.33
+ '@swc/html-darwin-x64': 1.15.33
+ '@swc/html-linux-arm-gnueabihf': 1.15.33
+ '@swc/html-linux-arm64-gnu': 1.15.33
+ '@swc/html-linux-arm64-musl': 1.15.33
+ '@swc/html-linux-ppc64-gnu': 1.15.33
+ '@swc/html-linux-s390x-gnu': 1.15.33
+ '@swc/html-linux-x64-gnu': 1.15.33
+ '@swc/html-linux-x64-musl': 1.15.33
+ '@swc/html-win32-arm64-msvc': 1.15.33
+ '@swc/html-win32-ia32-msvc': 1.15.33
+ '@swc/html-win32-x64-msvc': 1.15.33
+
+ '@swc/types@0.1.26':
+ dependencies:
+ '@swc/counter': 0.1.3
+
'@szmarczak/http-timer@5.0.1':
dependencies:
defer-to-connect: 2.0.1
@@ -8647,12 +9197,12 @@ snapshots:
postcss: 8.5.9
postcss-value-parser: 4.2.0
- babel-loader@9.2.1(@babel/core@7.28.4)(webpack@5.101.3):
+ babel-loader@9.2.1(@babel/core@7.28.4)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
'@babel/core': 7.28.4
find-cache-dir: 4.0.0
schema-utils: 4.3.2
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
babel-plugin-dynamic-import-node@2.3.3:
dependencies:
@@ -9027,7 +9577,7 @@ snapshots:
copy-text-to-clipboard@3.2.2: {}
- copy-webpack-plugin@11.0.0(webpack@5.101.3):
+ copy-webpack-plugin@11.0.0(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
fast-glob: 3.3.3
glob-parent: 6.0.2
@@ -9035,7 +9585,7 @@ snapshots:
normalize-path: 3.0.0
schema-utils: 4.3.2
serialize-javascript: 6.0.2
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
core-js-compat@3.45.1:
dependencies:
@@ -9082,7 +9632,7 @@ snapshots:
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
- css-loader@6.11.0(webpack@5.101.3):
+ css-loader@6.11.0(@rspack/core@1.7.11)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
icss-utils: 5.1.0(postcss@8.5.9)
postcss: 8.5.9
@@ -9093,9 +9643,10 @@ snapshots:
postcss-value-parser: 4.2.0
semver: 7.7.2
optionalDependencies:
- webpack: 5.101.3
+ '@rspack/core': 1.7.11
+ webpack: 5.101.3(@swc/core@1.15.33)
- css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.101.3):
+ css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
cssnano: 6.1.2(postcss@8.5.9)
@@ -9103,7 +9654,7 @@ snapshots:
postcss: 8.5.9
schema-utils: 4.3.2
serialize-javascript: 6.0.2
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
optionalDependencies:
clean-css: 5.3.3
@@ -9259,8 +9810,7 @@ snapshots:
destroy@1.2.0: {}
- detect-libc@2.1.2:
- optional: true
+ detect-libc@2.1.2: {}
detect-node@2.1.0: {}
@@ -9291,12 +9841,12 @@ snapshots:
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
- docusaurus-plugin-openapi-docs@5.0.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1):
+ docusaurus-plugin-openapi-docs@5.0.2(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1):
dependencies:
'@apidevtools/json-schema-ref-parser': 15.3.5(@types/json-schema@7.0.15)
- '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
- '@docusaurus/utils': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@docusaurus/utils-validation': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/plugin-content-docs': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/utils': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/utils-validation': 3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@redocly/openapi-core': 2.30.3
allof-merge: 0.6.8
chalk: 5.6.2
@@ -9316,20 +9866,20 @@ snapshots:
- '@types/json-schema'
- encoding
- docusaurus-plugin-sass@0.2.6(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(sass@1.99.0)(webpack@5.101.3):
+ docusaurus-plugin-sass@0.2.6(@docusaurus/core@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@rspack/core@1.7.11)(sass@1.99.0)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
- '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
+ '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3)
sass: 1.99.0
- sass-loader: 16.0.7(sass@1.99.0)(webpack@5.101.3)
+ sass-loader: 16.0.7(@rspack/core@1.7.11)(sass@1.99.0)(webpack@5.101.3(@swc/core@1.15.33))
transitivePeerDependencies:
- '@rspack/core'
- node-sass
- sass-embedded
- webpack
- docusaurus-theme-openapi-docs@5.0.1(@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@19.2.14)(docusaurus-plugin-openapi-docs@5.0.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1))(docusaurus-plugin-sass@0.2.6(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(sass@1.99.0)(webpack@5.101.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@5.0.1)(webpack@5.101.3):
+ docusaurus-theme-openapi-docs@5.0.2(e20b839675fe7ee566ace2729a870ef0):
dependencies:
- '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@hookform/error-message': 2.0.1(react-dom@18.3.1(react@18.3.1))(react-hook-form@7.74.0(react@18.3.1))(react@18.3.1)
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
allof-merge: 0.6.8
@@ -9337,8 +9887,8 @@ snapshots:
clsx: 2.1.1
copy-text-to-clipboard: 3.2.2
crypto-js: 4.2.0
- docusaurus-plugin-openapi-docs: 5.0.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1)
- docusaurus-plugin-sass: 0.2.6(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(sass@1.99.0)(webpack@5.101.3)
+ docusaurus-plugin-openapi-docs: 5.0.2(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@docusaurus/utils-validation@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@docusaurus/utils@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/json-schema@7.0.15)(react@18.3.1)
+ docusaurus-plugin-sass: 0.2.6(@docusaurus/core@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@rspack/core@1.7.11)(@swc/core@1.15.33)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@rspack/core@1.7.11)(sass@1.99.0)(webpack@5.101.3(@swc/core@1.15.33))
file-saver: 2.0.5
lodash: 4.17.23
pako: 2.1.0
@@ -9358,7 +9908,7 @@ snapshots:
rehype-raw: 7.0.0
remark-gfm: 4.0.1
sass: 1.99.0
- sass-loader: 16.0.7(sass@1.99.0)(webpack@5.101.3)
+ sass-loader: 16.0.7(@rspack/core@1.7.11)(sass@1.99.0)(webpack@5.101.3(@swc/core@1.15.33))
unist-util-visit: 5.0.0
url: 0.11.4
xml-formatter: 3.7.0
@@ -9661,11 +10211,11 @@ snapshots:
dependencies:
xml-js: 1.6.11
- file-loader@6.2.0(webpack@5.101.3):
+ file-loader@6.2.0(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
loader-utils: 2.0.4
schema-utils: 3.3.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
file-saver@2.0.5: {}
@@ -9781,7 +10331,7 @@ snapshots:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
- minimatch: 3.1.2
+ minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
@@ -10001,7 +10551,7 @@ snapshots:
html-void-elements@3.0.0: {}
- html-webpack-plugin@5.6.4(webpack@5.101.3):
+ html-webpack-plugin@5.6.4(@rspack/core@1.7.11)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
'@types/html-minifier-terser': 6.1.0
html-minifier-terser: 6.1.0
@@ -10009,7 +10559,8 @@ snapshots:
pretty-error: 4.0.0
tapable: 2.2.3
optionalDependencies:
- webpack: 5.101.3
+ '@rspack/core': 1.7.11
+ webpack: 5.101.3(@swc/core@1.15.33)
htmlparser2@10.0.0:
dependencies:
@@ -10352,6 +10903,55 @@ snapshots:
leven@3.1.0: {}
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -10951,18 +11551,14 @@ snapshots:
mimic-response@4.0.0: {}
- mini-css-extract-plugin@2.9.4(webpack@5.101.3):
+ mini-css-extract-plugin@2.9.4(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
schema-utils: 4.3.2
tapable: 2.2.3
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
minimalistic-assert@1.0.1: {}
- minimatch@3.1.2:
- dependencies:
- brace-expansion: 1.1.12
-
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
@@ -11045,11 +11641,11 @@ snapshots:
dependencies:
boolbase: 1.0.0
- null-loader@4.0.1(webpack@5.101.3):
+ null-loader@4.0.1(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
loader-utils: 2.0.4
schema-utils: 3.3.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
oas-kit-common@1.0.8:
dependencies:
@@ -11452,13 +12048,13 @@ snapshots:
postcss: 8.5.9
yaml: 2.8.1
- postcss-loader@7.3.4(postcss@8.5.9)(typescript@6.0.3)(webpack@5.101.3):
+ postcss-loader@7.3.4(postcss@8.5.9)(typescript@6.0.3)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7
postcss: 8.5.9
semver: 7.7.2
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- typescript
@@ -11885,11 +12481,11 @@ snapshots:
sucrase: 3.35.1
use-editable: 2.3.3(react@18.3.1)
- react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.101.3):
+ react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
'@babel/runtime': 7.29.2
react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)'
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
react-magic-dropzone@1.0.1: {}
@@ -12199,12 +12795,13 @@ snapshots:
safer-buffer@2.1.2: {}
- sass-loader@16.0.7(sass@1.99.0)(webpack@5.101.3):
+ sass-loader@16.0.7(@rspack/core@1.7.11)(sass@1.99.0)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
neo-async: 2.6.2
optionalDependencies:
+ '@rspack/core': 1.7.11
sass: 1.99.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
sass@1.99.0:
dependencies:
@@ -12592,6 +13189,12 @@ snapshots:
transitivePeerDependencies:
- encoding
+ swc-loader@0.2.7(@swc/core@1.15.33)(webpack@5.101.3(@swc/core@1.15.33)):
+ dependencies:
+ '@swc/core': 1.15.33
+ '@swc/counter': 0.1.3
+ webpack: 5.101.3(@swc/core@1.15.33)
+
tailwindcss@3.4.19(yaml@2.8.1):
dependencies:
'@alloc/quick-lru': 5.2.0
@@ -12622,14 +13225,16 @@ snapshots:
tapable@2.2.3: {}
- terser-webpack-plugin@5.3.14(webpack@5.101.3):
+ terser-webpack-plugin@5.3.14(@swc/core@1.15.33)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.44.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
+ optionalDependencies:
+ '@swc/core': 1.15.33
terser@5.44.0:
dependencies:
@@ -12791,14 +13396,14 @@ snapshots:
dependencies:
punycode: 2.3.1
- url-loader@4.1.1(file-loader@6.2.0(webpack@5.101.3))(webpack@5.101.3):
+ url-loader@4.1.1(file-loader@6.2.0(webpack@5.101.3(@swc/core@1.15.33)))(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
loader-utils: 2.0.4
mime-types: 2.1.35
schema-utils: 3.3.0
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
optionalDependencies:
- file-loader: 6.2.0(webpack@5.101.3)
+ file-loader: 6.2.0(webpack@5.101.3(@swc/core@1.15.33))
url@0.11.4:
dependencies:
@@ -12892,7 +13497,7 @@ snapshots:
- bufferutil
- utf-8-validate
- webpack-dev-middleware@7.4.5(webpack@5.101.3):
+ webpack-dev-middleware@7.4.5(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
colorette: 2.0.20
memfs: 4.47.0
@@ -12901,9 +13506,9 @@ snapshots:
range-parser: 1.2.1
schema-utils: 4.3.2
optionalDependencies:
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
- webpack-dev-server@5.2.2(debug@4.4.3)(webpack@5.101.3):
+ webpack-dev-server@5.2.2(debug@4.4.3)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
'@types/bonjour': 3.5.13
'@types/connect-history-api-fallback': 1.5.4
@@ -12931,10 +13536,10 @@ snapshots:
serve-index: 1.9.1
sockjs: 0.3.24
spdy: 4.0.2
- webpack-dev-middleware: 7.4.5(webpack@5.101.3)
+ webpack-dev-middleware: 7.4.5(webpack@5.101.3(@swc/core@1.15.33))
ws: 8.18.3
optionalDependencies:
- webpack: 5.101.3
+ webpack: 5.101.3(@swc/core@1.15.33)
transitivePeerDependencies:
- bufferutil
- debug
@@ -12955,7 +13560,7 @@ snapshots:
webpack-sources@3.3.3: {}
- webpack@5.101.3:
+ webpack@5.101.3(@swc/core@1.15.33):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@@ -12979,7 +13584,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.2
tapable: 2.2.3
- terser-webpack-plugin: 5.3.14(webpack@5.101.3)
+ terser-webpack-plugin: 5.3.14(@swc/core@1.15.33)(webpack@5.101.3(@swc/core@1.15.33))
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
@@ -12987,14 +13592,15 @@ snapshots:
- esbuild
- uglify-js
- webpackbar@7.0.0(webpack@5.101.3):
+ webpackbar@7.0.0(@rspack/core@1.7.11)(webpack@5.101.3(@swc/core@1.15.33)):
dependencies:
ansis: 3.17.0
consola: 3.4.2
pretty-time: 1.1.0
std-env: 3.9.0
optionalDependencies:
- webpack: 5.101.3
+ '@rspack/core': 1.7.11
+ webpack: 5.101.3(@swc/core@1.15.33)
websocket-driver@0.7.4:
dependencies:
diff --git a/gen-docs/sidebars.ts b/gen-docs/sidebars.ts
index bad106a1f5..9e08e5f084 100644
--- a/gen-docs/sidebars.ts
+++ b/gen-docs/sidebars.ts
@@ -1,4 +1,6 @@
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
+// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
+import apiSidebar from '../docs/api/sidebar';
/**
* Creating a sidebar enables you to:
@@ -13,6 +15,7 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
documentationSidebar: [{ type: 'autogenerated', dirName: '.' }],
+ apisidebar: apiSidebar,
// But you can create a sidebar manually
/*
diff --git a/next-env.d.ts b/next-env.d.ts
deleted file mode 100644
index a4a7b3f5cf..0000000000
--- a/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
diff --git a/next.config.js b/next.config.ts
similarity index 59%
rename from next.config.js
rename to next.config.ts
index 9f15b7ff46..a3b5414f06 100644
--- a/next.config.js
+++ b/next.config.ts
@@ -1,9 +1,9 @@
-/**
- * @type {import('next').NextConfig}
- */
-module.exports = {
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
+ unsafeDoNotUseDemo: process.env.UNSAFE_DO_NOT_USE_DEMO,
},
images: {
remotePatterns: [
@@ -14,17 +14,18 @@ module.exports = {
],
},
transpilePackages: ['country-flag-icons'],
- webpack(config) {
- config.module.rules.push({
- test: /\.svg$/,
- issuer: /\.(js|ts)x?$/,
- use: ['@svgr/webpack'],
- });
-
- return config;
+ turbopack: {
+ rules: {
+ '*.svg': {
+ loaders: ['@svgr/webpack'],
+ as: '*.js',
+ },
+ },
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 512 * 1000,
},
};
+
+export default nextConfig;
diff --git a/package.json b/package.json
index fc94c76635..9e16cf4ed2 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,13 @@
{
"name": "seerr",
- "version": "0.1.0",
+ "version": "3.5.0",
"private": true,
"packageManager": "pnpm@10.24.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"postinstall": "next telemetry disable",
"dev": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'",
- "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.json",
+ "build:server": "tsc --project server/tsconfig.build.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.build.json",
"build:next": "next build",
"build": "pnpm build:next && pnpm build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
@@ -23,7 +23,7 @@
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
"typecheck:client": "tsc --noEmit",
- "prepare": "node bin/prepare.js",
+ "prepare": "node bin/prepare.mjs",
"cypress:open": "cypress open",
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
"cypress:build": "pnpm build && pnpm cypress:prepare"
@@ -38,7 +38,7 @@
"@fontsource-variable/inter": "^5.2.8",
"@formatjs/intl": "^4.1.4",
"@formatjs/intl-locale": "5.3.2",
- "@headlessui/react": "1.7.19",
+ "@headlessui/react": "2.2.10",
"@heroicons/react": "2.2.0",
"@react-spring/web": "^10.0.3",
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
@@ -68,21 +68,22 @@
"humanize-duration": "^3.33.2",
"js-yaml": "^4.1.1",
"lodash": "4.18.1",
+ "lru-cache": "11.2.1",
"mime": "^4.1.0",
"nanoid": "^5.1.7",
- "next": "^14.2.35",
- "node-cache": "5.1.2",
+ "next": "16.2.6",
+ "ip-address": "^10.2.0",
"node-gyp": "12.2.0",
"node-schedule": "2.1.1",
"nodemailer": "8.0.5",
"openpgp": "6.3.0",
- "pg": "8.20.0",
+ "pg": "8.23.0",
"pug": "3.0.4",
- "react": "^18.3.1",
+ "react": "19.2.6",
"react-ace": "14.0.1",
"react-animate-height": "3.2.3",
"react-aria": "3.47.0",
- "react-dom": "^18.3.1",
+ "react-dom": "19.2.6",
"react-hot-toast": "^2.6.0",
"react-intersection-observer": "10.0.3",
"react-intl": "^7.1.14",
@@ -99,7 +100,7 @@
"swagger-ui-express": "5.0.1",
"swr": "2.4.1",
"tailwind-merge": "^2.6.1",
- "typeorm": "0.3.28",
+ "typeorm": "0.3.31",
"ua-parser-js": "^2.0.9",
"undici": "^8.1.0",
"validator": "^13.15.35",
@@ -115,7 +116,7 @@
"@commitlint/cli": "20.5.0",
"@commitlint/config-conventional": "20.5.0",
"@eslint/js": "9.39.3",
- "@next/eslint-plugin-next": "^16.2.3",
+ "@next/eslint-plugin-next": "16.2.6",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
@@ -130,12 +131,11 @@
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "4.17.24",
- "@types/mime": "^3.0.4",
"@types/node": "22.19.0",
"@types/node-schedule": "2.1.8",
"@types/nodemailer": "^8.0.0",
- "@types/react": "^18.3.28",
- "@types/react-dom": "^18.3.7",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
"@types/react-transition-group": "4.4.12",
"@types/semver": "7.7.1",
"@types/supertest": "^7.2.0",
@@ -154,6 +154,7 @@
"cypress": "15.13.1",
"cz-conventional-changelog": "3.3.0",
"eslint": "9.39.3",
+ "eslint-config-next": "16.2.6",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-formatjs": "6.4.5",
"eslint-plugin-jsx-a11y": "6.10.2",
@@ -162,7 +163,7 @@
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "^17.5.0",
- "husky": "8.0.3",
+ "husky": "9.1.7",
"jiti": "^2.6.1",
"lint-staged": "16.4.0",
"nodemon": "3.1.14",
@@ -211,7 +212,9 @@
],
"overrides": {
"sqlite3>node-gyp": "8.4.1",
- "@types/express-session": "1.18.2"
+ "@types/express-session": "1.18.2",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3"
}
},
"scarfSettings": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3143a9e1cb..829703b19a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,8 @@ settings:
overrides:
sqlite3>node-gyp: 8.4.1
'@types/express-session': 1.18.2
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3
importers:
@@ -25,17 +27,17 @@ importers:
specifier: 5.3.2
version: 5.3.2
'@headlessui/react':
- specifier: 1.7.19
- version: 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: 2.2.10
+ version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@heroicons/react':
specifier: 2.2.0
- version: 2.2.0(react@18.3.1)
+ version: 2.2.0(react@19.2.6)
'@react-spring/web':
specifier: ^10.0.3
- version: 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 10.0.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@seerr-team/react-tailwindcss-datepicker':
specifier: ^1.3.4
- version: 1.3.4(dayjs@1.11.20)(react@18.3.1)
+ version: 1.3.4(dayjs@1.11.23)(react@19.2.6)
'@supercharge/request-ip':
specifier: 1.2.0
version: 1.2.0
@@ -44,7 +46,7 @@ importers:
version: 8.1.0(typescript@5.4.5)
'@tanem/react-nprogress':
specifier: 6.0.3
- version: 6.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 6.0.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
ace-builds:
specifier: 1.43.6
version: 1.43.6
@@ -62,7 +64,7 @@ importers:
version: 2.14.1
connect-typeorm:
specifier: 2.0.0
- version: 2.0.0(typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))
+ version: 2.0.0(typeorm@0.3.31(babel-plugin-macros@3.1.0)(pg@8.23.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))
cookie-parser:
specifier: 1.4.7
version: 1.4.7
@@ -80,7 +82,7 @@ importers:
version: 0.2.9
email-templates:
specifier: 13.0.1
- version: 13.0.1(@babel/core@7.29.0)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)
+ version: 13.0.1(@babel/core@7.29.0)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(underscore@1.13.7)
express:
specifier: 5.2.1
version: 5.2.1
@@ -95,7 +97,7 @@ importers:
version: 1.19.0
formik:
specifier: ^2.4.9
- version: 2.4.9(react@18.3.1)
+ version: 2.4.9(react@19.2.6)
gravatar-url:
specifier: 4.0.1
version: 4.0.1
@@ -108,24 +110,27 @@ importers:
humanize-duration:
specifier: ^3.33.2
version: 3.33.2
+ ip-address:
+ specifier: ^10.2.0
+ version: 10.2.0
js-yaml:
specifier: ^4.1.1
version: 4.1.1
lodash:
specifier: 4.18.1
version: 4.18.1
+ lru-cache:
+ specifier: 11.2.1
+ version: 11.2.1
mime:
specifier: ^4.1.0
version: 4.1.0
nanoid:
specifier: ^5.1.7
- version: 5.1.9
+ version: 5.1.11
next:
- specifier: ^14.2.35
- version: 14.2.35(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- node-cache:
- specifier: 5.1.2
- version: 5.1.2
+ specifier: 16.2.6
+ version: 16.2.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
node-gyp:
specifier: 12.2.0
version: 12.2.0
@@ -139,53 +144,53 @@ importers:
specifier: 6.3.0
version: 6.3.0
pg:
- specifier: 8.20.0
- version: 8.20.0
+ specifier: 8.23.0
+ version: 8.23.0
pug:
specifier: 3.0.4
version: 3.0.4
react:
- specifier: ^18.3.1
- version: 18.3.1
+ specifier: 19.2.6
+ version: 19.2.6
react-ace:
specifier: 14.0.1
- version: 14.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 14.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-animate-height:
specifier: 3.2.3
- version: 3.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 3.2.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-aria:
specifier: 3.47.0
- version: 3.47.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 3.47.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-dom:
- specifier: ^18.3.1
- version: 18.3.1(react@18.3.1)
+ specifier: 19.2.6
+ version: 19.2.6(react@19.2.6)
react-hot-toast:
specifier: ^2.6.0
- version: 2.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-intersection-observer:
specifier: 10.0.3
- version: 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 10.0.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-intl:
specifier: ^7.1.14
- version: 7.1.14(react@18.3.1)(typescript@5.4.5)
+ version: 7.1.14(react@19.2.6)(typescript@5.4.5)
react-markdown:
specifier: 10.1.0
- version: 10.1.0(@types/react@18.3.28)(react@18.3.1)
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.6)
react-popper-tooltip:
specifier: 4.4.2
- version: 4.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 4.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-select:
specifier: 5.10.2
- version: 5.10.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 5.10.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-transition-group:
specifier: ^4.4.5
- version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-truncate-markup:
specifier: 5.1.2
- version: 5.1.2(react@18.3.1)
+ version: 5.1.2(react@19.2.6)
react-use-clipboard:
specifier: 1.0.9
- version: 1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 1.0.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -203,13 +208,13 @@ importers:
version: 5.0.1(express@5.2.1)
swr:
specifier: 2.4.1
- version: 2.4.1(react@18.3.1)
+ version: 2.4.1(react@19.2.6)
tailwind-merge:
specifier: ^2.6.1
version: 2.6.1
typeorm:
- specifier: 0.3.28
- version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ specifier: 0.3.31
+ version: 0.3.31(babel-plugin-macros@3.1.0)(pg@8.23.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
ua-parser-js:
specifier: ^2.0.9
version: 2.0.9
@@ -251,8 +256,8 @@ importers:
specifier: 9.39.3
version: 9.39.3
'@next/eslint-plugin-next':
- specifier: ^16.2.3
- version: 16.2.4
+ specifier: 16.2.6
+ version: 16.2.6
'@tailwindcss/aspect-ratio':
specifier: ^0.4.2
version: 0.4.2(tailwindcss@3.4.19(yaml@2.8.3))
@@ -295,9 +300,6 @@ importers:
'@types/lodash':
specifier: 4.17.24
version: 4.17.24
- '@types/mime':
- specifier: ^3.0.4
- version: 3.0.4
'@types/node':
specifier: 22.19.0
version: 22.19.0
@@ -308,14 +310,14 @@ importers:
specifier: ^8.0.0
version: 8.0.0
'@types/react':
- specifier: ^18.3.28
- version: 18.3.28
+ specifier: 19.2.14
+ version: 19.2.14
'@types/react-dom':
- specifier: ^18.3.7
- version: 18.3.7(@types/react@18.3.28)
+ specifier: 19.2.3
+ version: 19.2.3(@types/react@19.2.14)
'@types/react-transition-group':
specifier: 4.4.12
- version: 4.4.12(@types/react@18.3.28)
+ version: 4.4.12(@types/react@19.2.14)
'@types/semver':
specifier: 7.7.1
version: 7.7.1
@@ -367,12 +369,15 @@ importers:
eslint:
specifier: 9.39.3
version: 9.39.3(jiti@2.6.1)
+ eslint-config-next:
+ specifier: 16.2.6
+ version: 16.2.6(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5)
eslint-config-prettier:
specifier: 10.1.8
version: 10.1.8(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-formatjs:
specifier: 6.4.5
- version: 6.4.5(eslint@9.39.3(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5))
+ version: 6.4.5(eslint@9.39.3(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5))
eslint-plugin-jsx-a11y:
specifier: 6.10.2
version: 6.10.2(eslint@9.39.3(jiti@2.6.1))
@@ -392,8 +397,8 @@ importers:
specifier: ^17.5.0
version: 17.5.0
husky:
- specifier: 8.0.3
- version: 8.0.3
+ specifier: 9.1.7
+ version: 9.1.7
jiti:
specifier: ^2.6.1
version: 2.6.1
@@ -423,7 +428,7 @@ importers:
version: 3.4.19(yaml@2.8.3)
ts-node:
specifier: 10.9.2
- version: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)
+ version: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)
tsc-alias:
specifier: 1.8.16
version: 1.8.16
@@ -1269,14 +1274,26 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@floating-ui/core@1.7.3':
- resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
+ '@floating-ui/core@1.8.0':
+ resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
- '@floating-ui/dom@1.7.4':
- resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
+ '@floating-ui/dom@1.8.0':
+ resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
- '@floating-ui/utils@0.2.10':
- resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+ '@floating-ui/react-dom@2.1.9':
+ resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/react@0.26.28':
+ resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.12':
+ resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
@@ -1347,12 +1364,12 @@ packages:
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
- '@headlessui/react@1.7.19':
- resolution: {integrity: sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==}
+ '@headlessui/react@2.2.10':
+ resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==}
engines: {node: '>=10'}
peerDependencies:
- react: ^16 || ^17 || ^18
- react-dom: ^16 || ^17 || ^18
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
@@ -1823,62 +1840,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
- '@next/env@14.2.35':
- resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
+ '@next/env@16.2.6':
+ resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
- '@next/eslint-plugin-next@16.2.4':
- resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==}
+ '@next/eslint-plugin-next@16.2.6':
+ resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
- '@next/swc-darwin-arm64@14.2.33':
- resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==}
+ '@next/swc-darwin-arm64@16.2.6':
+ resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@14.2.33':
- resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==}
+ '@next/swc-darwin-x64@16.2.6':
+ resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@14.2.33':
- resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==}
+ '@next/swc-linux-arm64-gnu@16.2.6':
+ resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-musl@14.2.33':
- resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
+ '@next/swc-linux-arm64-musl@16.2.6':
+ resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-x64-gnu@14.2.33':
- resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
+ '@next/swc-linux-x64-gnu@16.2.6':
+ resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-musl@14.2.33':
- resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
+ '@next/swc-linux-x64-musl@16.2.6':
+ resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-win32-arm64-msvc@14.2.33':
- resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
+ '@next/swc-win32-arm64-msvc@16.2.6':
+ resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-ia32-msvc@14.2.33':
- resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==}
- engines: {node: '>= 10'}
- cpu: [ia32]
- os: [win32]
-
- '@next/swc-win32-x64-msvc@14.2.33':
- resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==}
+ '@next/swc-win32-x64-msvc@16.2.6':
+ resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1899,6 +1910,10 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@nolyfill/is-core-module@1.0.39':
+ resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
+ engines: {node: '>=12.4.0'}
+
'@npmcli/agent@4.0.0':
resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -2205,6 +2220,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+ '@rtsao/scc@1.1.0':
+ resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+
'@seerr-team/react-tailwindcss-datepicker@1.3.4':
resolution: {integrity: sha512-KZrnl6WL1lvUnAG4RZIkReJ+E0vSpOtMEuatobMqiWAa5Y+Z3d0ZcOOJWHoeRNtF19sIzzBkuLyhFNFlNtXg3A==}
peerDependencies:
@@ -2390,11 +2408,8 @@ packages:
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
- '@swc/helpers@0.5.11':
- resolution: {integrity: sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==}
-
- '@swc/helpers@0.5.5':
- resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
+ '@swc/helpers@0.5.15':
+ resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.26':
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
@@ -2501,9 +2516,6 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
- '@types/express-serve-static-core@4.19.5':
- resolution: {integrity: sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==}
-
'@types/express-serve-static-core@5.1.1':
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
@@ -2543,6 +2555,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/json5@0.0.29':
+ resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
@@ -2555,9 +2570,6 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
- '@types/mime@3.0.4':
- resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
-
'@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
@@ -2582,27 +2594,24 @@ packages:
'@types/picomatch@4.0.2':
resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==}
- '@types/prop-types@15.7.15':
- resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
-
'@types/qs@6.9.15':
resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
- '@types/react-dom@18.3.7':
- resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
- '@types/react': ^18.0.0
+ '@types/react': 19.2.14
'@types/react-transition-group@4.4.12':
resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==}
peerDependencies:
- '@types/react': '*'
+ '@types/react': 19.2.14
- '@types/react@18.3.28':
- resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
@@ -2610,9 +2619,6 @@ packages:
'@types/send@0.17.4':
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
- '@types/serve-static@1.15.7':
- resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
-
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
@@ -2731,6 +2737,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unicode/unicode-17.0.0@1.6.16':
resolution: {integrity: sha512-advq5p36zZ+PDRUpDkWcHHR++R19kx0LYB5iG3bj0KB8mYVKg0ywS996e2bXeXxDb8XdOF7KTivcx7VkYie1pg==}
@@ -2941,8 +2948,8 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
- ansis@4.2.0:
- resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
+ ansis@4.3.1:
+ resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
engines: {node: '>=14'}
any-promise@1.3.0:
@@ -3001,8 +3008,8 @@ packages:
array-ify@1.0.0:
resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
- array-includes@3.1.8:
- resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==}
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
engines: {node: '>= 0.4'}
array-union@2.1.0:
@@ -3013,8 +3020,12 @@ packages:
resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
engines: {node: '>= 0.4'}
- array.prototype.flat@1.3.2:
- resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==}
+ array.prototype.findlastindex@1.2.6:
+ resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
engines: {node: '>= 0.4'}
array.prototype.flatmap@1.3.3:
@@ -3426,10 +3437,6 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
- clone@2.1.2:
- resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
- engines: {node: '>=0.8'}
-
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -3661,6 +3668,7 @@ packages:
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
+ deprecated: v4 is no longer maintained, upgrade to v5
cronstrue@3.14.0:
resolution: {integrity: sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==}
@@ -3735,8 +3743,8 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
- dayjs@1.11.20:
- resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
+ dayjs@1.11.23:
+ resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
@@ -4031,8 +4039,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
- es-shim-unscopables@1.0.2:
- resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==}
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
es-to-primitive@1.3.0:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
@@ -4065,17 +4074,73 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ eslint-config-next@16.2.6:
+ resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==}
+ peerDependencies:
+ eslint: '>=9.0.0'
+ typescript: '>=3.3.1'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
+ eslint-import-resolver-node@0.3.10:
+ resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
+
+ eslint-import-resolver-typescript@3.10.1:
+ resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '*'
+ eslint-plugin-import: '*'
+ eslint-plugin-import-x: '*'
+ peerDependenciesMeta:
+ eslint-plugin-import:
+ optional: true
+ eslint-plugin-import-x:
+ optional: true
+
+ eslint-module-utils@2.12.1:
+ resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: '*'
+ eslint-import-resolver-node: '*'
+ eslint-import-resolver-typescript: '*'
+ eslint-import-resolver-webpack: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ eslint:
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+ eslint-import-resolver-typescript:
+ optional: true
+ eslint-import-resolver-webpack:
+ optional: true
+
eslint-plugin-formatjs@6.4.5:
resolution: {integrity: sha512-a42E0lErGJMy/cz57JdM6zdLuqBHF3i6Pv2j8Pw9XIBBkSIvJ1s98axTq2CznJ4ukmR0QZ1P+3upEx3kxSkbJQ==}
peerDependencies:
eslint: 9 || 10
+ eslint-plugin-import@2.32.0:
+ resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+
eslint-plugin-jsx-a11y@6.10.2:
resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
engines: {node: '>=4.0'}
@@ -4130,6 +4195,7 @@ packages:
eslint@9.39.3:
resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -4505,6 +4571,7 @@ packages:
git-raw-commits@5.0.1:
resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==}
engines: {node: '>=18'}
+ deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
hasBin: true
github-from-package@0.0.0:
@@ -4551,6 +4618,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
+ globals@16.4.0:
+ resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
+ engines: {node: '>=18'}
+
globals@17.5.0:
resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==}
engines: {node: '>=18'}
@@ -4711,9 +4782,9 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
- husky@8.0.3:
- resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==}
- engines: {node: '>=14'}
+ husky@9.1.7:
+ resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
+ engines: {node: '>=18'}
hasBin: true
i18n-locales@0.0.5:
@@ -4811,6 +4882,10 @@ packages:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
+ ip-address@10.2.0:
+ resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
+ engines: {node: '>= 12'}
+
ip-address@9.0.5:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
@@ -4848,6 +4923,9 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
+ is-bun-module@2.0.0:
+ resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
+
is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
@@ -5248,6 +5326,10 @@ packages:
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
+ json5@1.0.2:
+ resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
+ hasBin: true
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -5798,13 +5880,13 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
- nanoid@3.3.11:
- resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@5.1.9:
- resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==}
+ nanoid@5.1.11:
+ resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
@@ -5830,21 +5912,24 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
- next@14.2.35:
- resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==}
- engines: {node: '>=18.17.0'}
+ next@16.2.6:
+ resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
+ engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
- '@playwright/test': ^1.41.2
- react: ^18.2.0
- react-dom: ^18.2.0
+ '@playwright/test': ^1.51.1
+ babel-plugin-react-compiler: '*'
+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
+ babel-plugin-react-compiler:
+ optional: true
sass:
optional: true
@@ -5865,9 +5950,9 @@ packages:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
- node-cache@5.1.2:
- resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==}
- engines: {node: '>= 8.0.0'}
+ node-exports-info@1.6.0:
+ resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
+ engines: {node: '>= 0.4'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
@@ -5984,6 +6069,10 @@ packages:
resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
engines: {node: '>= 0.4'}
+ object.groupby@1.0.3:
+ resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
+ engines: {node: '>= 0.4'}
+
object.values@1.2.1:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
@@ -6167,30 +6256,30 @@ packages:
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
- pg-cloudflare@1.3.0:
- resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
+ pg-cloudflare@1.4.0:
+ resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
- pg-connection-string@2.12.0:
- resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
+ pg-connection-string@2.14.0:
+ resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
- pg-pool@3.13.0:
- resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
+ pg-pool@3.14.0:
+ resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies:
pg: '>=8.0'
- pg-protocol@1.13.0:
- resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
+ pg-protocol@1.16.0:
+ resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
- pg@8.20.0:
- resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
+ pg@8.23.0:
+ resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
@@ -6554,10 +6643,10 @@ packages:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
- react-dom@18.3.1:
- resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ react-dom@19.2.6:
+ resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
- react: ^18.3.1
+ react: ^19.2.6
react-fast-compare@2.0.4:
resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
@@ -6599,7 +6688,7 @@ packages:
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
- '@types/react': '>=18'
+ '@types/react': 19.2.14
react: '>=18'
react-popper-tooltip@4.4.2:
@@ -6643,8 +6732,8 @@ packages:
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
- react@18.3.1:
- resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ react@19.2.6:
+ resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
read-cache@1.0.0:
@@ -6740,8 +6829,9 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
- resolve@2.0.0-next.5:
- resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
+ resolve@2.0.0-next.6:
+ resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==}
+ engines: {node: '>= 0.4'}
hasBin: true
restore-cursor@3.1.0:
@@ -6821,8 +6911,8 @@ packages:
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
engines: {node: '>=11.0.0'}
- scheduler@0.23.2:
- resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -7017,6 +7107,9 @@ packages:
resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==}
engines: {node: '>= 8'}
+ stable-hash@0.0.5:
+ resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
@@ -7133,13 +7226,13 @@ packages:
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
- styled-jsx@5.1.1:
- resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
+ styled-jsx@5.1.6:
+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
peerDependencies:
'@babel/core': '*'
babel-plugin-macros: '*'
- react: '>= 16.8.0 || 17.x.x || ^18.0.0-0'
+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
peerDependenciesMeta:
'@babel/core':
optional: true
@@ -7210,6 +7303,9 @@ packages:
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
hasBin: true
+ tabbable@6.5.0:
+ resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
+
tailwind-merge@2.6.1:
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
@@ -7402,6 +7498,9 @@ packages:
engines: {node: '>=16.20.2'}
hasBin: true
+ tsconfig-paths@3.15.0:
+ resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+
tsconfig-paths@4.2.0:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
@@ -7477,8 +7576,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
- typeorm@0.3.28:
- resolution: {integrity: sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==}
+ typeorm@0.3.31:
+ resolution: {integrity: sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==}
engines: {node: '>=16.13.0'}
hasBin: true
peerDependencies:
@@ -7489,13 +7588,13 @@ packages:
mongodb: ^5.8.0 || ^6.0.0
mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0
mysql2: ^2.2.5 || ^3.0.1
- oracledb: ^6.3.0
+ oracledb: ^6.3.0 || ^7.0.0
pg: ^8.5.1
pg-native: ^3.0.0
pg-query-stream: ^4.0.0
redis: ^3.1.1 || ^4.0.0 || ^5.0.14
sql.js: ^1.4.0
- sqlite3: ^5.0.3
+ sqlite3: ^5.0.3 || ^6.0.0
ts-node: ^10.7.0
typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0
peerDependenciesMeta:
@@ -7676,16 +7775,18 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
- uuid@11.1.0:
- resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+ uuid@11.1.1:
+ resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-compile-cache-lib@3.0.1:
@@ -7889,8 +7990,8 @@ packages:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
- yargs@17.7.2:
- resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ yargs@17.7.3:
+ resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
yauzl@2.10.0:
@@ -8762,7 +8863,7 @@ snapshots:
'@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0)
'@commitlint/types': 20.5.0
tinyexec: 1.1.1
- yargs: 17.7.2
+ yargs: 17.7.3
transitivePeerDependencies:
- '@types/node'
- conventional-commits-filter
@@ -8968,19 +9069,19 @@ snapshots:
'@emotion/memoize@0.9.0': {}
- '@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1)':
+ '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.2
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
- '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1)
+ '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6)
'@emotion/utils': 1.4.2
'@emotion/weak-memoize': 0.4.0
hoist-non-react-statics: 3.3.2
- react: 18.3.1
+ react: 19.2.6
optionalDependencies:
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
transitivePeerDependencies:
- supports-color
@@ -8996,9 +9097,9 @@ snapshots:
'@emotion/unitless@0.10.0': {}
- '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)':
+ '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.6)':
dependencies:
- react: 18.3.1
+ react: 19.2.6
'@emotion/utils@1.4.2': {}
@@ -9050,16 +9151,30 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
- '@floating-ui/core@1.7.3':
+ '@floating-ui/core@1.8.0':
+ dependencies:
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/dom@1.8.0':
dependencies:
- '@floating-ui/utils': 0.2.10
+ '@floating-ui/core': 1.8.0
+ '@floating-ui/utils': 0.2.12
- '@floating-ui/dom@1.7.4':
+ '@floating-ui/react-dom@2.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@floating-ui/core': 1.7.3
- '@floating-ui/utils': 0.2.10
+ '@floating-ui/dom': 1.8.0
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- '@floating-ui/utils@0.2.10': {}
+ '@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@floating-ui/utils': 0.2.12
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ tabbable: 6.5.0
+
+ '@floating-ui/utils@0.2.12': {}
'@fontsource-variable/inter@5.2.8': {}
@@ -9130,30 +9245,33 @@ snapshots:
'@formatjs/icu-messageformat-parser': 3.5.5
intl-messageformat: 11.2.2
- '@formatjs/ts-transformer@4.4.4(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5))':
+ '@formatjs/ts-transformer@4.4.4(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5))':
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.4
'@types/node': 22.19.0
json-stable-stringify: 1.3.0
typescript: 5.9.3
optionalDependencies:
- ts-jest: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5)
+ ts-jest: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5)
'@gar/promise-retry@1.0.3': {}
'@gar/promisify@1.1.3':
optional: true
- '@headlessui/react@1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@headlessui/react@2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@tanstack/react-virtual': 3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- client-only: 0.0.1
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ '@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/focus': 3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/interactions': 3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@tanstack/react-virtual': 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ use-sync-external-store: 1.6.0(react@19.2.6)
- '@heroicons/react@2.2.0(react@18.3.1)':
+ '@heroicons/react@2.2.0(react@19.2.6)':
dependencies:
- react: 18.3.1
+ react: 19.2.6
'@humanfs/core@0.19.1': {}
@@ -9264,20 +9382,20 @@ snapshots:
'@internationalized/date@3.12.1':
dependencies:
- '@swc/helpers': 0.5.11
+ '@swc/helpers': 0.5.15
'@internationalized/message@3.1.9':
dependencies:
- '@swc/helpers': 0.5.11
+ '@swc/helpers': 0.5.15
intl-messageformat: 10.7.18
'@internationalized/number@3.6.6':
dependencies:
- '@swc/helpers': 0.5.11
+ '@swc/helpers': 0.5.15
'@internationalized/string@3.2.8':
dependencies:
- '@swc/helpers': 0.5.11
+ '@swc/helpers': 0.5.15
'@isaacs/cliui@8.0.2':
dependencies:
@@ -9314,7 +9432,7 @@ snapshots:
slash: 3.0.0
optional: true
- '@jest/core@30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))':
+ '@jest/core@30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))':
dependencies:
'@jest/console': 30.2.0
'@jest/pattern': 30.0.1
@@ -9329,7 +9447,7 @@ snapshots:
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.2.0
- jest-config: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ jest-config: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
jest-haste-map: 30.2.0
jest-message-util: 30.2.0
jest-regex-util: 30.0.1
@@ -9527,15 +9645,15 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
- '@ladjs/consolidate@1.0.4(@babel/core@7.29.0)(handlebars@4.7.8)(lodash@4.18.1)(mustache@4.2.0)(pug@3.0.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)':
+ '@ladjs/consolidate@1.0.4(@babel/core@7.29.0)(handlebars@4.7.8)(lodash@4.18.1)(mustache@4.2.0)(pug@3.0.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(underscore@1.13.7)':
optionalDependencies:
'@babel/core': 7.29.0
handlebars: 4.7.8
lodash: 4.18.1
mustache: 4.2.0
pug: 3.0.4
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
underscore: 1.13.7
'@ladjs/country-language@0.2.1':
@@ -9587,37 +9705,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@next/env@14.2.35': {}
+ '@next/env@16.2.6': {}
- '@next/eslint-plugin-next@16.2.4':
+ '@next/eslint-plugin-next@16.2.6':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@14.2.33':
+ '@next/swc-darwin-arm64@16.2.6':
optional: true
- '@next/swc-darwin-x64@14.2.33':
+ '@next/swc-darwin-x64@16.2.6':
optional: true
- '@next/swc-linux-arm64-gnu@14.2.33':
+ '@next/swc-linux-arm64-gnu@16.2.6':
optional: true
- '@next/swc-linux-arm64-musl@14.2.33':
+ '@next/swc-linux-arm64-musl@16.2.6':
optional: true
- '@next/swc-linux-x64-gnu@14.2.33':
+ '@next/swc-linux-x64-gnu@16.2.6':
optional: true
- '@next/swc-linux-x64-musl@14.2.33':
+ '@next/swc-linux-x64-musl@16.2.6':
optional: true
- '@next/swc-win32-arm64-msvc@14.2.33':
+ '@next/swc-win32-arm64-msvc@16.2.6':
optional: true
- '@next/swc-win32-ia32-msvc@14.2.33':
- optional: true
-
- '@next/swc-win32-x64-msvc@14.2.33':
+ '@next/swc-win32-x64-msvc@16.2.6':
optional: true
'@noble/hashes@1.8.0': {}
@@ -9634,6 +9749,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1
+ '@nolyfill/is-core-module@1.0.39': {}
+
'@npmcli/agent@4.0.0':
dependencies:
agent-base: 7.1.3
@@ -9673,336 +9790,338 @@ snapshots:
'@popperjs/core@2.11.8': {}
- '@react-aria/breadcrumbs@3.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/breadcrumbs@3.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/button@3.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/button@3.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/calendar@3.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/calendar@3.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
- react-stately: 3.46.0(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
+ react-stately: 3.46.0(react@19.2.6)
- '@react-aria/checkbox@3.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/checkbox@3.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/color@3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/color@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/combobox@3.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/combobox@3.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/datepicker@3.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/datepicker@3.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
- react-stately: 3.46.0(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
+ react-stately: 3.46.0(react@19.2.6)
- '@react-aria/dialog@3.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/dialog@3.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/disclosure@3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/disclosure@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/dnd@3.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/dnd@3.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/focus@3.22.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/focus@3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/gridlist@3.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/gridlist@3.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/i18n@3.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/i18n@3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@internationalized/date': 3.12.1
'@internationalized/message': 3.1.9
'@internationalized/string': 3.2.8
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/interactions@3.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/interactions@3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/label@3.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/label@3.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/landmark@3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/landmark@3.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/link@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/link@3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/listbox@3.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/listbox@3.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/menu@3.22.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/menu@3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/meter@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/meter@3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/numberfield@3.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/numberfield@3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/overlays@3.32.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/overlays@3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/progress@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/progress@3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/radio@3.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/radio@3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/searchfield@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/searchfield@3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/select@3.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/select@3.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/selection@3.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/selection@3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/separator@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/separator@3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/slider@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/slider@3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/ssr@3.9.10(react@18.3.1)':
+ '@react-aria/ssr@3.9.10(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
- '@react-aria/switch@3.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/switch@3.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/table@3.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/table@3.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/tabs@3.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/tabs@3.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/tag@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/tag@3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/textfield@3.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/textfield@3.19.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/toast@3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/toast@3.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/tooltip@3.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/tooltip@3.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
- react-stately: 3.46.0(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
+ react-stately: 3.46.0(react@19.2.6)
- '@react-aria/tree@3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/tree@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-aria/utils@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/utils@3.34.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
- react-stately: 3.46.0(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
+ react-stately: 3.46.0(react@19.2.6)
- '@react-aria/visually-hidden@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-aria/visually-hidden@3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@swc/helpers': 0.5.11
- react: 18.3.1
- react-aria: 3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-dom: 18.3.1(react@18.3.1)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
- '@react-spring/animated@10.0.3(react@18.3.1)':
+ '@react-spring/animated@10.0.3(react@19.2.6)':
dependencies:
- '@react-spring/shared': 10.0.3(react@18.3.1)
+ '@react-spring/shared': 10.0.3(react@19.2.6)
'@react-spring/types': 10.0.3
- react: 18.3.1
+ react: 19.2.6
- '@react-spring/core@10.0.3(react@18.3.1)':
+ '@react-spring/core@10.0.3(react@19.2.6)':
dependencies:
- '@react-spring/animated': 10.0.3(react@18.3.1)
- '@react-spring/shared': 10.0.3(react@18.3.1)
+ '@react-spring/animated': 10.0.3(react@19.2.6)
+ '@react-spring/shared': 10.0.3(react@19.2.6)
'@react-spring/types': 10.0.3
- react: 18.3.1
+ react: 19.2.6
'@react-spring/rafz@10.0.3': {}
- '@react-spring/shared@10.0.3(react@18.3.1)':
+ '@react-spring/shared@10.0.3(react@19.2.6)':
dependencies:
'@react-spring/rafz': 10.0.3
'@react-spring/types': 10.0.3
- react: 18.3.1
+ react: 19.2.6
'@react-spring/types@10.0.3': {}
- '@react-spring/web@10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@react-spring/web@10.0.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@react-spring/animated': 10.0.3(react@18.3.1)
- '@react-spring/core': 10.0.3(react@18.3.1)
- '@react-spring/shared': 10.0.3(react@18.3.1)
+ '@react-spring/animated': 10.0.3(react@19.2.6)
+ '@react-spring/core': 10.0.3(react@19.2.6)
+ '@react-spring/shared': 10.0.3(react@19.2.6)
'@react-spring/types': 10.0.3
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- '@react-types/shared@3.34.0(react@18.3.1)':
+ '@react-types/shared@3.34.0(react@19.2.6)':
dependencies:
- react: 18.3.1
+ react: 19.2.6
+
+ '@rtsao/scc@1.1.0': {}
- '@seerr-team/react-tailwindcss-datepicker@1.3.4(dayjs@1.11.20)(react@18.3.1)':
+ '@seerr-team/react-tailwindcss-datepicker@1.3.4(dayjs@1.11.23)(react@19.2.6)':
dependencies:
- dayjs: 1.11.20
- react: 18.3.1
+ dayjs: 1.11.23
+ react: 19.2.6
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
@@ -10160,7 +10279,7 @@ snapshots:
'@swc/core-win32-x64-msvc@1.6.5':
optional: true
- '@swc/core@1.6.5(@swc/helpers@0.5.11)':
+ '@swc/core@1.6.5(@swc/helpers@0.5.15)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.26
@@ -10175,18 +10294,14 @@ snapshots:
'@swc/core-win32-arm64-msvc': 1.6.5
'@swc/core-win32-ia32-msvc': 1.6.5
'@swc/core-win32-x64-msvc': 1.6.5
- '@swc/helpers': 0.5.11
+ '@swc/helpers': 0.5.15
optional: true
- '@swc/counter@0.1.3': {}
-
- '@swc/helpers@0.5.11':
- dependencies:
- tslib: 2.8.1
+ '@swc/counter@0.1.3':
+ optional: true
- '@swc/helpers@0.5.5':
+ '@swc/helpers@0.5.15':
dependencies:
- '@swc/counter': 0.1.3
tslib: 2.8.1
'@swc/types@0.1.26':
@@ -10208,18 +10323,18 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(yaml@2.8.3)
- '@tanem/react-nprogress@6.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@tanem/react-nprogress@6.0.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.2
hoist-non-react-statics: 3.3.2
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- '@tanstack/react-virtual@3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@tanstack/react-virtual@3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@tanstack/virtual-core': 3.14.0
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
'@tanstack/virtual-core@3.14.0': {}
@@ -10287,7 +10402,7 @@ snapshots:
'@types/csurf@1.11.5':
dependencies:
- '@types/express-serve-static-core': 4.19.5
+ '@types/express-serve-static-core': 5.1.1
'@types/debug@0.0.31': {}
@@ -10316,13 +10431,6 @@ snapshots:
'@types/estree@1.0.8': {}
- '@types/express-serve-static-core@4.19.5':
- dependencies:
- '@types/node': 22.19.0
- '@types/qs': 6.9.15
- '@types/range-parser': 1.2.7
- '@types/send': 0.17.4
-
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 22.19.0
@@ -10346,7 +10454,7 @@ snapshots:
'@types/hoist-non-react-statics@3.3.5':
dependencies:
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
hoist-non-react-statics: 3.3.2
'@types/html-to-text@9.0.4': {}
@@ -10372,6 +10480,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/json5@0.0.29': {}
+
'@types/lodash@4.17.24': {}
'@types/mdast@4.0.4':
@@ -10382,8 +10492,6 @@ snapshots:
'@types/mime@1.3.5': {}
- '@types/mime@3.0.4': {}
-
'@types/minimatch@3.0.5': {}
'@types/ms@0.7.34': {}
@@ -10408,23 +10516,20 @@ snapshots:
'@types/picomatch@4.0.2': {}
- '@types/prop-types@15.7.15': {}
-
'@types/qs@6.9.15': {}
'@types/range-parser@1.2.7': {}
- '@types/react-dom@18.3.7(@types/react@18.3.28)':
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
- '@types/react-transition-group@4.4.12(@types/react@18.3.28)':
+ '@types/react-transition-group@4.4.12(@types/react@19.2.14)':
dependencies:
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
- '@types/react@18.3.28':
+ '@types/react@19.2.14':
dependencies:
- '@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/semver@7.7.1': {}
@@ -10434,12 +10539,6 @@ snapshots:
'@types/mime': 1.3.5
'@types/node': 22.19.0
- '@types/serve-static@1.15.7':
- dependencies:
- '@types/http-errors': 2.0.4
- '@types/node': 22.19.0
- '@types/send': 0.17.4
-
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.4
@@ -10467,7 +10566,7 @@ snapshots:
'@types/swagger-ui-express@4.1.8':
dependencies:
'@types/express': 5.0.6
- '@types/serve-static': 1.15.7
+ '@types/serve-static': 2.2.0
'@types/tmp@0.2.6': {}
@@ -10756,7 +10855,7 @@ snapshots:
ansi-styles@6.2.3: {}
- ansis@4.2.0: {}
+ ansis@4.3.1: {}
any-promise@1.3.0: {}
@@ -10806,14 +10905,16 @@ snapshots:
array-ify@1.0.0: {}
- array-includes@3.1.8:
+ array-includes@3.1.9:
dependencies:
call-bind: 1.0.8
+ call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.0
es-object-atoms: 1.1.1
get-intrinsic: 1.3.0
is-string: 1.1.1
+ math-intrinsics: 1.1.0
array-union@2.1.0: {}
@@ -10824,21 +10925,31 @@ snapshots:
es-abstract: 1.24.0
es-errors: 1.3.0
es-object-atoms: 1.1.1
- es-shim-unscopables: 1.0.2
+ es-shim-unscopables: 1.1.0
- array.prototype.flat@1.3.2:
+ array.prototype.findlastindex@1.2.6:
dependencies:
call-bind: 1.0.8
+ call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.0
- es-shim-unscopables: 1.0.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.0
+ es-shim-unscopables: 1.1.0
array.prototype.flatmap@1.3.3:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-abstract: 1.24.0
- es-shim-unscopables: 1.0.2
+ es-shim-unscopables: 1.1.0
array.prototype.tosorted@1.1.4:
dependencies:
@@ -10846,7 +10957,7 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.24.0
es-errors: 1.3.0
- es-shim-unscopables: 1.0.2
+ es-shim-unscopables: 1.1.0
arraybuffer.prototype.slice@1.0.4:
dependencies:
@@ -11345,8 +11456,6 @@ snapshots:
clone@1.0.4: {}
- clone@2.1.2: {}
-
clsx@2.1.1: {}
co@4.6.0:
@@ -11448,13 +11557,13 @@ snapshots:
readable-stream: 3.6.2
typedarray: 0.0.6
- connect-typeorm@2.0.0(typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))):
+ connect-typeorm@2.0.0(typeorm@0.3.31(babel-plugin-macros@3.1.0)(pg@8.23.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))):
dependencies:
'@types/debug': 0.0.31
'@types/express-session': 1.18.2
debug: 4.4.3(supports-color@5.5.0)
express-session: 1.19.0
- typeorm: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ typeorm: 0.3.31(babel-plugin-macros@3.1.0)(pg@8.23.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
transitivePeerDependencies:
- supports-color
@@ -11644,7 +11753,7 @@ snapshots:
cli-table3: 0.6.1
commander: 6.2.1
common-tags: 1.8.2
- dayjs: 1.11.20
+ dayjs: 1.11.23
debug: 4.4.3(supports-color@8.1.1)
enquirer: 2.4.1
eventemitter2: 6.4.7
@@ -11710,7 +11819,7 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
- dayjs@1.11.20: {}
+ dayjs@1.11.23: {}
debug@2.6.9:
dependencies:
@@ -11906,9 +12015,9 @@ snapshots:
electron-to-chromium@1.5.344: {}
- email-templates@13.0.1(@babel/core@7.29.0)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7):
+ email-templates@13.0.1(@babel/core@7.29.0)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(underscore@1.13.7):
dependencies:
- '@ladjs/consolidate': 1.0.4(@babel/core@7.29.0)(handlebars@4.7.8)(lodash@4.18.1)(mustache@4.2.0)(pug@3.0.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)
+ '@ladjs/consolidate': 1.0.4(@babel/core@7.29.0)(handlebars@4.7.8)(lodash@4.18.1)(mustache@4.2.0)(pug@3.0.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(underscore@1.13.7)
'@ladjs/i18n': 10.0.0
get-paths: 0.0.7
html-to-text: 9.0.5
@@ -12110,7 +12219,7 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
- es-shim-unscopables@1.0.2:
+ es-shim-unscopables@1.1.0:
dependencies:
hasown: 2.0.2
@@ -12136,14 +12245,68 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ eslint-config-next@16.2.6(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5):
+ dependencies:
+ '@next/eslint-plugin-next': 16.2.6
+ eslint: 9.39.3(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.10
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1))
+ eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1))
+ eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1))
+ eslint-plugin-react-hooks: 7.1.1(eslint@9.39.3(jiti@2.6.1))
+ globals: 16.4.0
+ typescript-eslint: 8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5)
+ optionalDependencies:
+ typescript: 5.4.5
+ transitivePeerDependencies:
+ - '@typescript-eslint/parser'
+ - eslint-import-resolver-webpack
+ - eslint-plugin-import-x
+ - supports-color
+
eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)):
dependencies:
eslint: 9.39.3(jiti@2.6.1)
- eslint-plugin-formatjs@6.4.5(eslint@9.39.3(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5)):
+ eslint-import-resolver-node@0.3.10:
+ dependencies:
+ debug: 3.2.7(supports-color@8.1.1)
+ is-core-module: 2.16.1
+ resolve: 2.0.0-next.6
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)):
+ dependencies:
+ '@nolyfill/is-core-module': 1.0.39
+ debug: 4.4.3(supports-color@5.5.0)
+ eslint: 9.39.3(jiti@2.6.1)
+ get-tsconfig: 4.13.0
+ is-bun-module: 2.0.0
+ stable-hash: 0.0.5
+ tinyglobby: 0.2.15
+ unrs-resolver: 1.11.1
+ optionalDependencies:
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1))
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)):
+ dependencies:
+ debug: 3.2.7(supports-color@8.1.1)
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5)
+ eslint: 9.39.3(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.10
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1))
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-formatjs@6.4.5(eslint@9.39.3(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5)):
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.4
- '@formatjs/ts-transformer': 4.4.4(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5))
+ '@formatjs/ts-transformer': 4.4.4(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5))
'@types/picomatch': 4.0.2
'@unicode/unicode-17.0.0': 1.6.16
eslint: 9.39.3(jiti@2.6.1)
@@ -12152,10 +12315,39 @@ snapshots:
transitivePeerDependencies:
- ts-jest
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)):
+ dependencies:
+ '@rtsao/scc': 1.1.0
+ array-includes: 3.1.9
+ array.prototype.findlastindex: 1.2.6
+ array.prototype.flat: 1.3.3
+ array.prototype.flatmap: 1.3.3
+ debug: 3.2.7(supports-color@8.1.1)
+ doctrine: 2.1.0
+ eslint: 9.39.3(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.10
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1))
+ hasown: 2.0.2
+ is-core-module: 2.16.1
+ is-glob: 4.0.3
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ object.groupby: 1.0.3
+ object.values: 1.2.1
+ semver: 6.3.1
+ string.prototype.trimend: 1.0.9
+ tsconfig-paths: 3.15.0
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.4.5)
+ transitivePeerDependencies:
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
+
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.3(jiti@2.6.1)):
dependencies:
aria-query: 5.3.2
- array-includes: 3.1.8
+ array-includes: 3.1.9
array.prototype.flatmap: 1.3.3
ast-types-flow: 0.0.8
axe-core: 4.11.0
@@ -12195,7 +12387,7 @@ snapshots:
eslint-plugin-react@7.37.5(eslint@9.39.3(jiti@2.6.1)):
dependencies:
- array-includes: 3.1.8
+ array-includes: 3.1.9
array.prototype.findlast: 1.2.5
array.prototype.flatmap: 1.3.3
array.prototype.tosorted: 1.1.4
@@ -12210,7 +12402,7 @@ snapshots:
object.fromentries: 2.0.8
object.values: 1.2.1
prop-types: 15.8.1
- resolve: 2.0.0-next.5
+ resolve: 2.0.0-next.6
semver: 6.3.1
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
@@ -12608,14 +12800,14 @@ snapshots:
dezalgo: 1.0.4
once: 1.4.0
- formik@2.4.9(react@18.3.1):
+ formik@2.4.9(react@19.2.6):
dependencies:
'@types/hoist-non-react-statics': 3.3.5
deepmerge: 2.2.1
hoist-non-react-statics: 3.3.2
lodash: 4.18.1
lodash-es: 4.17.23
- react: 18.3.1
+ react: 19.2.6
react-fast-compare: 2.0.4
tiny-warning: 1.0.3
tslib: 2.8.1
@@ -12797,6 +12989,8 @@ snapshots:
globals@14.0.0: {}
+ globals@16.4.0: {}
+
globals@17.5.0: {}
globalthis@1.0.4:
@@ -13011,7 +13205,7 @@ snapshots:
ms: 2.1.3
optional: true
- husky@8.0.3: {}
+ husky@9.1.7: {}
i18n-locales@0.0.5:
dependencies:
@@ -13121,6 +13315,8 @@ snapshots:
ip-address@10.1.0: {}
+ ip-address@10.2.0: {}
+
ip-address@9.0.5:
dependencies:
jsbn: 1.1.0
@@ -13160,6 +13356,10 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-bun-module@2.0.0:
+ dependencies:
+ semver: 7.7.4
+
is-callable@1.2.7: {}
is-core-module@2.16.1:
@@ -13399,18 +13599,18 @@ snapshots:
- supports-color
optional: true
- jest-cli@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)):
+ jest-cli@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)):
dependencies:
- '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ jest-config: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
jest-util: 30.2.0
jest-validate: 30.2.0
- yargs: 17.7.2
+ yargs: 17.7.3
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -13419,7 +13619,7 @@ snapshots:
- ts-node
optional: true
- jest-config@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)):
+ jest-config@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)):
dependencies:
'@babel/core': 7.29.0
'@jest/get-type': 30.1.0
@@ -13447,7 +13647,7 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.0
- ts-node: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)
+ ts-node: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -13688,12 +13888,12 @@ snapshots:
supports-color: 8.1.1
optional: true
- jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)):
+ jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)):
dependencies:
- '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
'@jest/types': 30.2.0
import-local: 3.2.0
- jest-cli: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ jest-cli: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -13748,6 +13948,10 @@ snapshots:
json-stringify-safe@5.0.1: {}
+ json5@1.0.2:
+ dependencies:
+ minimist: 1.2.8
+
json5@2.2.3: {}
jsonfile@6.1.0:
@@ -13772,8 +13976,8 @@ snapshots:
jsx-ast-utils@3.3.5:
dependencies:
- array-includes: 3.1.8
- array.prototype.flat: 1.3.2
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
object.assign: 4.1.7
object.values: 1.2.1
@@ -14470,14 +14674,13 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
- nanoid@3.3.11: {}
+ nanoid@3.3.12: {}
- nanoid@5.1.9: {}
+ nanoid@5.1.11: {}
napi-build-utils@2.0.0: {}
- napi-postinstall@0.3.4:
- optional: true
+ napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -14489,27 +14692,26 @@ snapshots:
neo-async@2.6.2:
optional: true
- next@14.2.35(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ next@16.2.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- '@next/env': 14.2.35
- '@swc/helpers': 0.5.5
- busboy: 1.6.0
+ '@next/env': 16.2.6
+ '@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.10.23
caniuse-lite: 1.0.30001791
- graceful-fs: 4.2.11
postcss: 8.4.31
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- styled-jsx: 5.1.1(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ styled-jsx: 5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.6)
optionalDependencies:
- '@next/swc-darwin-arm64': 14.2.33
- '@next/swc-darwin-x64': 14.2.33
- '@next/swc-linux-arm64-gnu': 14.2.33
- '@next/swc-linux-arm64-musl': 14.2.33
- '@next/swc-linux-x64-gnu': 14.2.33
- '@next/swc-linux-x64-musl': 14.2.33
- '@next/swc-win32-arm64-msvc': 14.2.33
- '@next/swc-win32-ia32-msvc': 14.2.33
- '@next/swc-win32-x64-msvc': 14.2.33
+ '@next/swc-darwin-arm64': 16.2.6
+ '@next/swc-darwin-x64': 16.2.6
+ '@next/swc-linux-arm64-gnu': 16.2.6
+ '@next/swc-linux-arm64-musl': 16.2.6
+ '@next/swc-linux-x64-gnu': 16.2.6
+ '@next/swc-linux-x64-musl': 16.2.6
+ '@next/swc-win32-arm64-msvc': 16.2.6
+ '@next/swc-win32-x64-msvc': 16.2.6
+ sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -14530,9 +14732,12 @@ snapshots:
node-addon-api@8.5.0: {}
- node-cache@5.1.2:
+ node-exports-info@1.6.0:
dependencies:
- clone: 2.1.2
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
@@ -14676,6 +14881,12 @@ snapshots:
es-abstract: 1.24.0
es-object-atoms: 1.1.1
+ object.groupby@1.0.3:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.0
+
object.values@1.2.1:
dependencies:
call-bind: 1.0.8
@@ -14875,18 +15086,18 @@ snapshots:
performance-now@2.1.0: {}
- pg-cloudflare@1.3.0:
+ pg-cloudflare@1.4.0:
optional: true
- pg-connection-string@2.12.0: {}
+ pg-connection-string@2.14.0: {}
pg-int8@1.0.1: {}
- pg-pool@3.13.0(pg@8.20.0):
+ pg-pool@3.14.0(pg@8.23.0):
dependencies:
- pg: 8.20.0
+ pg: 8.23.0
- pg-protocol@1.13.0: {}
+ pg-protocol@1.16.0: {}
pg-types@2.2.0:
dependencies:
@@ -14896,15 +15107,15 @@ snapshots:
postgres-date: 1.0.7
postgres-interval: 1.2.0
- pg@8.20.0:
+ pg@8.23.0:
dependencies:
- pg-connection-string: 2.12.0
- pg-pool: 3.13.0(pg@8.20.0)
- pg-protocol: 1.13.0
+ pg-connection-string: 2.14.0
+ pg-pool: 3.14.0(pg@8.23.0)
+ pg-protocol: 1.16.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
- pg-cloudflare: 1.3.0
+ pg-cloudflare: 1.4.0
pgpass@1.0.5:
dependencies:
@@ -14972,13 +15183,13 @@ snapshots:
postcss@8.4.31:
dependencies:
- nanoid: 3.3.11
+ nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.12:
dependencies:
- nanoid: 3.3.11
+ nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -15196,115 +15407,114 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
- react-ace@14.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-ace@14.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
ace-builds: 1.43.6
diff-match-patch: 1.0.5
lodash.get: 4.4.2
lodash.isequal: 4.5.0
prop-types: 15.8.1
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- react-animate-height@3.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-animate-height@3.2.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- react-aria@3.47.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-aria@3.47.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@internationalized/string': 3.2.8
- '@react-aria/breadcrumbs': 3.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/button': 3.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/calendar': 3.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/checkbox': 3.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/color': 3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/combobox': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/datepicker': 3.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/dialog': 3.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/disclosure': 3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/dnd': 3.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/focus': 3.22.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/gridlist': 3.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/i18n': 3.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/interactions': 3.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/label': 3.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/landmark': 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/link': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/listbox': 3.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/menu': 3.22.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/meter': 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/numberfield': 3.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/overlays': 3.32.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/progress': 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/radio': 3.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/searchfield': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/select': 3.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/selection': 3.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/separator': 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/slider': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/ssr': 3.9.10(react@18.3.1)
- '@react-aria/switch': 3.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/table': 3.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/tabs': 3.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/tag': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/textfield': 3.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/toast': 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/tooltip': 3.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/tree': 3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/utils': 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-aria/visually-hidden': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@react-types/shared': 3.34.0(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
-
- react-aria@3.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ '@react-aria/breadcrumbs': 3.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/button': 3.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/calendar': 3.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/checkbox': 3.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/color': 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/combobox': 3.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/datepicker': 3.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/dialog': 3.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/disclosure': 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/dnd': 3.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/focus': 3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/gridlist': 3.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/i18n': 3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/interactions': 3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/label': 3.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/landmark': 3.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/link': 3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/listbox': 3.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/menu': 3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/meter': 3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/numberfield': 3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/overlays': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/progress': 3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/radio': 3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/searchfield': 3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/select': 3.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/selection': 3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/separator': 3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/slider': 3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/ssr': 3.9.10(react@19.2.6)
+ '@react-aria/switch': 3.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/table': 3.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/tabs': 3.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/tag': 3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/textfield': 3.19.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/toast': 3.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/tooltip': 3.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/tree': 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/utils': 3.34.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-aria/visually-hidden': 3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
+ react-aria@3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
'@internationalized/string': 3.2.8
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
aria-hidden: 1.2.6
clsx: 2.1.1
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- react-stately: 3.46.0(react@18.3.1)
- use-sync-external-store: 1.6.0(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-stately: 3.46.0(react@19.2.6)
+ use-sync-external-store: 1.6.0(react@19.2.6)
- react-dom@18.3.1(react@18.3.1):
+ react-dom@19.2.6(react@19.2.6):
dependencies:
- loose-envify: 1.4.0
- react: 18.3.1
- scheduler: 0.23.2
+ react: 19.2.6
+ scheduler: 0.27.0
react-fast-compare@2.0.4: {}
react-fast-compare@3.2.2: {}
- react-hot-toast@2.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-hot-toast@2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
csstype: 3.2.3
goober: 2.1.18(csstype@3.2.3)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- react-intersection-observer@10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-intersection-observer@10.0.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- react: 18.3.1
+ react: 19.2.6
optionalDependencies:
- react-dom: 18.3.1(react@18.3.1)
+ react-dom: 19.2.6(react@19.2.6)
- react-intl@7.1.14(react@18.3.1)(typescript@5.4.5):
+ react-intl@7.1.14(react@19.2.6)(typescript@5.4.5):
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/icu-messageformat-parser': 2.11.4
'@formatjs/intl': 3.1.8(typescript@5.4.5)
'@types/hoist-non-react-statics': 3.3.5
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
hoist-non-react-statics: 3.3.2
intl-messageformat: 10.7.18
- react: 18.3.1
+ react: 19.2.6
tslib: 2.8.1
optionalDependencies:
typescript: 5.4.5
@@ -15314,16 +15524,16 @@ snapshots:
react-is@18.3.1:
optional: true
- react-markdown@10.1.0(@types/react@18.3.28)(react@18.3.1):
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.6):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
- react: 18.3.1
+ react: 19.2.6
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
@@ -15332,75 +15542,73 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-popper-tooltip@4.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-popper-tooltip@4.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@popperjs/core': 2.11.8
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@popperjs/core': 2.11.8
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
react-fast-compare: 3.2.2
warning: 4.0.3
- react-select@5.10.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-select@5.10.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@emotion/cache': 11.14.0
- '@emotion/react': 11.14.0(@types/react@18.3.28)(react@18.3.1)
- '@floating-ui/dom': 1.7.4
- '@types/react-transition-group': 4.4.12(@types/react@18.3.28)
+ '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6)
+ '@floating-ui/dom': 1.8.0
+ '@types/react-transition-group': 4.4.12(@types/react@19.2.14)
memoize-one: 6.0.0
prop-types: 15.8.1
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.28)(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- '@types/react'
- supports-color
- react-stately@3.46.0(react@18.3.1):
+ react-stately@3.46.0(react@19.2.6):
dependencies:
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
'@internationalized/string': 3.2.8
- '@react-types/shared': 3.34.0(react@18.3.1)
- '@swc/helpers': 0.5.11
- react: 18.3.1
- use-sync-external-store: 1.6.0(react@18.3.1)
+ '@react-types/shared': 3.34.0(react@19.2.6)
+ '@swc/helpers': 0.5.15
+ react: 19.2.6
+ use-sync-external-store: 1.6.0(react@19.2.6)
- react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-transition-group@4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- react-truncate-markup@5.1.2(react@18.3.1):
+ react-truncate-markup@5.1.2(react@19.2.6):
dependencies:
line-height: 0.3.1
memoize-one: 5.2.1
prop-types: 15.8.1
- react: 18.3.1
+ react: 19.2.6
resize-observer-polyfill: 1.5.1
- react-use-clipboard@1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-use-clipboard@1.0.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
copy-to-clipboard: 3.3.3
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
- react@18.3.1:
- dependencies:
- loose-envify: 1.4.0
+ react@19.2.6: {}
read-cache@1.0.0:
dependencies:
@@ -15529,9 +15737,12 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
- resolve@2.0.0-next.5:
+ resolve@2.0.0-next.6:
dependencies:
+ es-errors: 1.3.0
is-core-module: 2.16.1
+ node-exports-info: 1.6.0
+ object-keys: 1.1.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
@@ -15615,9 +15826,7 @@ snapshots:
sax@1.5.0: {}
- scheduler@0.23.2:
- dependencies:
- loose-envify: 1.4.0
+ scheduler@0.27.0: {}
selderee@0.11.0:
dependencies:
@@ -15891,6 +16100,8 @@ snapshots:
minipass: 3.3.6
optional: true
+ stable-hash@0.0.5: {}
+
stack-trace@0.0.10: {}
stack-utils@2.0.6:
@@ -16032,10 +16243,10 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
- styled-jsx@5.1.1(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@18.3.1):
+ styled-jsx@5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.6):
dependencies:
client-only: 0.0.1
- react: 18.3.1
+ react: 19.2.6
optionalDependencies:
'@babel/core': 7.29.0
babel-plugin-macros: 3.1.0
@@ -16107,11 +16318,11 @@ snapshots:
express: 5.2.1
swagger-ui-dist: 5.17.14
- swr@2.4.1(react@18.3.1):
+ swr@2.4.1(react@19.2.6):
dependencies:
dequal: 2.0.3
- react: 18.3.1
- use-sync-external-store: 1.6.0(react@18.3.1)
+ react: 19.2.6
+ use-sync-external-store: 1.6.0(react@19.2.6)
synckit@0.11.12:
dependencies:
@@ -16119,6 +16330,8 @@ snapshots:
systeminformation@5.31.5: {}
+ tabbable@6.5.0: {}
+
tailwind-merge@2.6.1: {}
tailwindcss@3.4.19(yaml@2.8.3):
@@ -16280,12 +16493,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5):
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)))(typescript@5.4.5):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5))
+ jest: 30.2.0(@types/node@22.19.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -16301,7 +16514,7 @@ snapshots:
jest-util: 30.2.0
optional: true
- ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5):
+ ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
@@ -16319,7 +16532,7 @@ snapshots:
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
- '@swc/core': 1.6.5(@swc/helpers@0.5.11)
+ '@swc/core': 1.6.5(@swc/helpers@0.5.15)
tsc-alias@1.8.16:
dependencies:
@@ -16331,6 +16544,13 @@ snapshots:
normalize-path: 3.0.0
plimit-lit: 1.6.1
+ tsconfig-paths@3.15.0:
+ dependencies:
+ '@types/json5': 0.0.29
+ json5: 1.0.2
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
tsconfig-paths@4.2.0:
dependencies:
json5: 2.2.3
@@ -16413,13 +16633,13 @@ snapshots:
typedarray@0.0.6: {}
- typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)):
+ typeorm@0.3.31(babel-plugin-macros@3.1.0)(pg@8.23.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)):
dependencies:
'@sqltools/formatter': 1.2.5
- ansis: 4.2.0
+ ansis: 4.3.1
app-root-path: 3.1.0
buffer: 6.0.3
- dayjs: 1.11.20
+ dayjs: 1.11.23
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
@@ -16428,12 +16648,12 @@ snapshots:
sha.js: 2.4.12
sql-highlight: 6.1.0
tslib: 2.8.1
- uuid: 11.1.0
- yargs: 17.7.2
+ uuid: 11.1.1
+ yargs: 17.7.3
optionalDependencies:
- pg: 8.20.0
+ pg: 8.23.0
sqlite3: 5.1.7
- ts-node: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.19.0)(typescript@5.4.5)
+ ts-node: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.15))(@types/node@22.19.0)(typescript@5.4.5)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -16573,7 +16793,6 @@ snapshots:
'@unrs/resolver-binding-win32-arm64-msvc': 1.11.1
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
- optional: true
untildify@4.0.0: {}
@@ -16587,19 +16806,19 @@ snapshots:
dependencies:
punycode: 2.3.1
- use-isomorphic-layout-effect@1.2.1(@types/react@18.3.28)(react@18.3.1):
+ use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.6):
dependencies:
- react: 18.3.1
+ react: 19.2.6
optionalDependencies:
- '@types/react': 18.3.28
+ '@types/react': 19.2.14
- use-sync-external-store@1.6.0(react@18.3.1):
+ use-sync-external-store@1.6.0(react@19.2.6):
dependencies:
- react: 18.3.1
+ react: 19.2.6
util-deprecate@1.0.2: {}
- uuid@11.1.0: {}
+ uuid@11.1.1: {}
uuid@8.3.2: {}
@@ -16861,7 +17080,7 @@ snapshots:
y18n: 5.0.8
yargs-parser: 20.2.9
- yargs@17.7.2:
+ yargs@17.7.3:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
diff --git a/seerr-api.yml b/seerr-api.yml
index e517722609..34d17b690b 100644
--- a/seerr-api.yml
+++ b/seerr-api.yml
@@ -148,10 +148,6 @@ components:
email:
type: string
example: 'user@example.com'
- discordId:
- type: string
- nullable: true
- example: '123456789'
locale:
type: string
nullable: true
@@ -236,6 +232,9 @@ components:
hideAvailable:
type: boolean
example: false
+ hideRequested:
+ type: boolean
+ example: false
partialRequestsEnabled:
type: boolean
example: false
@@ -254,6 +253,9 @@ components:
enableSpecialEpisodes:
type: boolean
example: false
+ versionCheck:
+ type: boolean
+ example: false
NetworkSettings:
type: object
properties:
@@ -695,6 +697,73 @@ components:
- is4k
- enableSeasonFolders
- isDefault
+ ReadarrSettings:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 0
+ readOnly: true
+ name:
+ type: string
+ example: 'Readarr Main'
+ hostname:
+ type: string
+ example: '127.0.0.1'
+ port:
+ type: number
+ example: 7878
+ apiKey:
+ type: string
+ example: 'exampleapikey'
+ useSsl:
+ type: boolean
+ example: false
+ baseUrl:
+ type: string
+ activeProfileId:
+ type: number
+ example: 1
+ activeProfileName:
+ type: string
+ example: ebook/Spoken
+ activeMetadataProfileId:
+ type: number
+ example: 1
+ activeMetadataProfileName:
+ type: string
+ example: Standard
+ activeDirectory:
+ type: string
+ example: '/books'
+ is4k:
+ type: boolean
+ example: false
+ isDefault:
+ type: boolean
+ example: false
+ externalUrl:
+ type: string
+ example: http://readarr.example.com
+ syncEnabled:
+ type: boolean
+ example: false
+ preventSearch:
+ type: boolean
+ example: false
+ required:
+ - name
+ - hostname
+ - port
+ - apiKey
+ - useSsl
+ - activeProfileId
+ - activeProfileName
+ - activeMetadataProfileId
+ - activeMetadataProfileName
+ - activeDirectory
+ - isDefault
+ - is4k
ServarrTag:
type: object
properties:
@@ -1271,6 +1340,10 @@ components:
type: number
rootFolder:
type: string
+ ignoreQuota:
+ type: boolean
+ example: false
+ description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission.
required:
- id
- status
@@ -1551,6 +1624,8 @@ components:
type: string
topic:
type: string
+ tags:
+ type: string
authMethodUsernamePassword:
type: boolean
username:
@@ -1561,6 +1636,10 @@ components:
type: boolean
token:
type: string
+ priority:
+ type: number
+ locale:
+ type: string
NotificationEmailSettings:
type: object
properties:
@@ -1952,8 +2031,10 @@ components:
discordEnabledTypes:
type: number
nullable: true
- discordId:
- type: string
+ discordIds:
+ type: array
+ items:
+ type: string
nullable: true
pushbulletAccessToken:
type: string
@@ -2131,6 +2212,195 @@ components:
- certification: 'PG'
meaning: 'Some material may not be suitable for children under 10.'
order: 2
+
+ BookDetails:
+ type: object
+ properties:
+ id:
+ type: number
+ title:
+ type: string
+ description:
+ type: string
+ releaseDate:
+ type: string
+ image:
+ type: object
+ properties:
+ url:
+ type: string
+ mediaInfo:
+ $ref: '#/components/schemas/MediaInfo'
+ author:
+ type: array
+ items:
+ $ref: '#/components/schemas/AuthorSummary'
+ pages:
+ type: number
+ tags:
+ $ref: '#/components/schemas/BookTags'
+ headline:
+ type: string
+ subtitle:
+ type: string
+ series:
+ type: array
+ items:
+ type: object
+ properties:
+ position:
+ type: number
+ series_id:
+ type: number
+ series:
+ $ref: '#/components/schemas/SeriesSummary'
+
+ BookResult:
+ type: object
+ properties:
+ id:
+ type: number
+ mediaType:
+ type: string
+ enum: [book]
+ title:
+ type: string
+ overview:
+ type: string
+ releaseDate:
+ type: string
+ posterPath:
+ type: string
+ nullable: true
+ position:
+ type: number
+ nullable: true
+ mediaInfo:
+ $ref: '#/components/schemas/MediaInfo'
+
+ AuthorDetails:
+ type: object
+ properties:
+ id:
+ type: number
+ name:
+ type: string
+ bio:
+ type: string
+ born_date:
+ type: string
+ books_count:
+ type: number
+ posterPath:
+ type: string
+ nullable: true
+ books:
+ type: array
+ items:
+ $ref: '#/components/schemas/BookResult'
+
+ AuthorResult:
+ type: object
+ properties:
+ id:
+ type: number
+ name:
+ type: string
+ image:
+ type: string
+ nullable: true
+ mediaType:
+ type: string
+ enum: [author]
+
+ AuthorSummary:
+ type: object
+ properties:
+ id:
+ type: number
+ name:
+ type: string
+ bio:
+ type: string
+ nullable: true
+ born_date:
+ type: string
+ nullable: true
+ image:
+ type: object
+ nullable: true
+ properties:
+ url:
+ type: string
+
+ Series:
+ type: object
+ properties:
+ id:
+ type: number
+ name:
+ type: string
+ overview:
+ type: string
+ nullable: true
+ posterPath:
+ type: string
+ nullable: true
+ backdropPath:
+ type: string
+ nullable: true
+ books:
+ type: array
+ items:
+ $ref: '#/components/schemas/BookResult'
+
+ SeriesSummary:
+ type: object
+ properties:
+ id:
+ type: number
+ name:
+ type: string
+ description:
+ type: string
+ nullable: true
+
+ BookTags:
+ type: object
+ properties:
+ Genre:
+ type: array
+ items:
+ $ref: '#/components/schemas/BookTag'
+ Mood:
+ type: array
+ items:
+ $ref: '#/components/schemas/BookTag'
+ 'Content Warning':
+ type: array
+ items:
+ $ref: '#/components/schemas/BookTag'
+ Tag:
+ type: array
+ items:
+ $ref: '#/components/schemas/BookTag'
+
+ BookTag:
+ type: object
+ properties:
+ tag:
+ type: string
+ tagSlug:
+ type: string
+ category:
+ type: string
+ categorySlug:
+ type: string
+ spoilerRatio:
+ type: number
+ count:
+ type: number
+
securitySchemes:
cookieAuth:
type: apiKey
@@ -2145,10 +2415,18 @@ paths:
/status:
get:
summary: Get Seerr status
- description: Returns the current Seerr status in a JSON object.
+ description: Returns the current Seerr status in a JSON object. updateAvailable and commitsBehind are omitted when checkUpdateAvailable is false.
security: []
tags:
- public
+ parameters:
+ - in: query
+ name: checkUpdateAvailable
+ description: If false, updateAvailable and commitsBehind will be omitted from the response. Defaults to the versionCheck setting.
+ required: false
+ example: false
+ schema:
+ type: boolean
responses:
'200':
description: Returned status
@@ -2302,23 +2580,58 @@ paths:
get:
summary: Get Jellyfin libraries
description: Returns a list of Jellyfin libraries in a JSON array.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: 'Jellyfin libraries returned'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/JellyfinLibrary'
+ /settings/jellyfin/library/{libraryId}:
+ put:
+ summary: Update a single Jellyfin library
+ description: Updates a single Jellyfin library with the provided values.
tags:
- settings
parameters:
- - in: query
- name: sync
- description: Syncs the current libraries with the current Jellyfin server
- schema:
- type: string
- nullable: true
- - in: query
- name: enable
- explode: false
- allowReserved: true
- description: Comma separated list of libraries to enable. Any libraries not passed will be disabled!
+ - in: path
+ name: libraryId
+ required: true
schema:
type: string
- nullable: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - enabled
+ properties:
+ enabled:
+ type: boolean
+ example: true
+ responses:
+ '200':
+ description: 'Jellyfin library returned'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JellyfinLibrary'
+ '400':
+ description: 'Invalid request body'
+ '404':
+ description: 'Library does not exist'
+ /settings/jellyfin/library/sync:
+ post:
+ summary: Sync Jellyfin libraries
+ description: Syncs the current libraries with the current Jellyfin server. Enabled flags are preserved.
+ tags:
+ - settings
responses:
'200':
description: 'Jellyfin libraries returned'
@@ -2328,6 +2641,10 @@ paths:
type: array
items:
$ref: '#/components/schemas/JellyfinLibrary'
+ '404':
+ description: 'No libraries found'
+ '501':
+ description: 'Automatic library grouping is not supported'
/settings/jellyfin/users:
get:
summary: Get Jellyfin Users
@@ -2457,23 +2774,58 @@ paths:
get:
summary: Get Plex libraries
description: Returns a list of Plex libraries in a JSON array.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: 'Plex libraries returned'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PlexLibrary'
+ /settings/plex/library/{libraryId}:
+ put:
+ summary: Update a single Plex library
+ description: Updates a single Plex library with the provided values.
tags:
- settings
parameters:
- - in: query
- name: sync
- description: Syncs the current libraries with the current Plex server
- schema:
- type: string
- nullable: true
- - in: query
- name: enable
- explode: false
- allowReserved: true
- description: Comma separated list of libraries to enable. Any libraries not passed will be disabled!
+ - in: path
+ name: libraryId
+ required: true
schema:
type: string
- nullable: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - enabled
+ properties:
+ enabled:
+ type: boolean
+ example: true
+ responses:
+ '200':
+ description: 'Plex library returned'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlexLibrary'
+ '400':
+ description: 'Invalid request body'
+ '404':
+ description: 'Library does not exist'
+ /settings/plex/library/sync:
+ post:
+ summary: Sync Plex libraries
+ description: Syncs the current libraries with the current Plex server. Enabled flags are preserved.
+ tags:
+ - settings
responses:
'200':
description: 'Plex libraries returned'
@@ -2955,6 +3307,173 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
+ /settings/readarr:
+ get:
+ summary: Get Readarr settings
+ description: Returns all Readarr settings in a JSON array.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: 'Values were returned'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ReadarrSettings'
+ post:
+ summary: Create Readarr instance
+ description: Creates a new Readarr instance from the request body.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadarrSettings'
+ responses:
+ '201':
+ description: 'New Readarr instance created'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadarrSettings'
+ /settings/readarr/test:
+ post:
+ summary: Test Readarr configuration
+ description: Tests if the Readarr configuration is valid. Returns profiles and root folders on success.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ hostname:
+ type: string
+ example: '127.0.0.1'
+ port:
+ type: number
+ example: 7878
+ apiKey:
+ type: string
+ example: yourapikey
+ useSsl:
+ type: boolean
+ example: false
+ baseUrl:
+ type: string
+ required:
+ - hostname
+ - port
+ - apiKey
+ - useSsl
+ responses:
+ '200':
+ description: Succesfully connected to Readarr instance
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ profiles:
+ type: array
+ items:
+ $ref: '#/components/schemas/ServiceProfile'
+ /settings/readarr/{readarrId}:
+ put:
+ summary: Update Readarr instance
+ description: Updates an existing Readarr instance with the provided values.
+ tags:
+ - settings
+ parameters:
+ - in: path
+ name: readarrId
+ required: true
+ schema:
+ type: integer
+ description: Readarr instance ID
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadarrSettings'
+ responses:
+ '200':
+ description: 'Readarr instance updated'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadarrSettings'
+ delete:
+ summary: Delete Readarr instance
+ description: Deletes an existing Readarr instance based on the readarrId parameter.
+ tags:
+ - settings
+ parameters:
+ - in: path
+ name: readarrId
+ required: true
+ schema:
+ type: integer
+ description: Readarr instance ID
+ responses:
+ '200':
+ description: 'Readarr instance deleted'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ReadarrSettings'
+ /settings/readarr/{readarrId}/profiles:
+ get:
+ summary: Get available Readarr profiles
+ description: Returns a list of profiles available on the Readarr server instance in a JSON array.
+ tags:
+ - settings
+ parameters:
+ - in: path
+ name: readarrId
+ required: true
+ schema:
+ type: integer
+ description: Readarr instance ID
+ responses:
+ '200':
+ description: Returned list of profiles
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ServiceProfile'
+ /settings/readarr/{readarrId}/metadataprofiles:
+ get:
+ summary: Get available Readarr metadata profiles
+ description: Returns a list of metadata profiles available on the Readarr server instance in a JSON array.
+ tags:
+ - settings
+ parameters:
+ - in: path
+ name: readarrId
+ required: true
+ schema:
+ type: integer
+ description: Readarr instance ID
+ responses:
+ '200':
+ description: Returned list of metadata profiles
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ServiceProfile'
+
/settings/public:
get:
summary: Get public settings
@@ -3965,13 +4484,90 @@ paths:
security: []
tags:
- auth
- responses:
- '200':
- description: OK
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/User'
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/User'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ username:
+ type: string
+ password:
+ type: string
+ hostname:
+ type: string
+ email:
+ type: string
+ serverType:
+ type: number
+ required:
+ - username
+ - password
+ /auth/jellyfin/quickconnect/initiate:
+ post:
+ summary: Initiate Jellyfin Quick Connect
+ description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
+ security: []
+ tags:
+ - auth
+ responses:
+ '200':
+ description: Quick Connect session initiated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code:
+ type: string
+ example: '123456'
+ secret:
+ type: string
+ example: 'abc123def456'
+ '500':
+ description: Failed to initiate Quick Connect
+ /auth/jellyfin/quickconnect/check:
+ get:
+ summary: Check Quick Connect authorization status
+ description: Checks if the Quick Connect code has been authorized by the user.
+ security: []
+ tags:
+ - auth
+ parameters:
+ - in: query
+ name: secret
+ required: true
+ schema:
+ type: string
+ description: The secret returned from the initiate endpoint
+ responses:
+ '200':
+ description: Authorization status returned
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ authenticated:
+ type: boolean
+ example: false
+ '404':
+ description: Quick Connect session not found or expired
+ /auth/jellyfin/quickconnect/authenticate:
+ post:
+ summary: Authenticate with Quick Connect
+ description: Completes the Quick Connect authentication flow and creates a user session.
+ security: []
+ tags:
+ - auth
requestBody:
required: true
content:
@@ -3979,19 +4575,21 @@ paths:
schema:
type: object
properties:
- username:
- type: string
- password:
- type: string
- hostname:
- type: string
- email:
+ secret:
type: string
- serverType:
- type: number
required:
- - username
- - password
+ - secret
+ responses:
+ '200':
+ description: Successfully authenticated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/User'
+ '403':
+ description: Quick Connect not authorized or access denied
+ '500':
+ description: Authentication failed
/auth/local:
post:
summary: Sign in using a local account
@@ -4667,6 +5265,7 @@ paths:
enum:
- movie
- tv
+ - book
responses:
'200':
description: Blocklist details in JSON
@@ -4690,6 +5289,7 @@ paths:
enum:
- movie
- tv
+ - book
responses:
'204':
description: Succesfully removed media item
@@ -4800,6 +5400,7 @@ paths:
enum:
- movie
- tv
+ - book
responses:
'200':
description: Blocklist details in JSON
@@ -4826,6 +5427,7 @@ paths:
enum:
- movie
- tv
+ - book
responses:
'204':
description: Succesfully removed media item
@@ -4912,6 +5514,7 @@ paths:
enum:
- movie
- tv
+ - book
responses:
'204':
description: Succesfully removed watchlist item
@@ -5155,6 +5758,40 @@ paths:
description: Unlink request invalid
'404':
description: User does not exist
+ /user/{userId}/settings/linked-accounts/jellyfin/quickconnect:
+ post:
+ summary: Link Jellyfin/Emby account with Quick Connect
+ description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication
+ tags:
+ - users
+ parameters:
+ - in: path
+ name: userId
+ required: true
+ schema:
+ type: number
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ secret:
+ type: string
+ required:
+ - secret
+ responses:
+ '400':
+ description: Invalid Quick Connect secret
+ '204':
+ description: Account successfully linked
+ '401':
+ description: Unauthorized
+ '422':
+ description: Account already linked
+ '500':
+ description: Server error
/user/{userId}/settings/notifications:
get:
summary: Get notification settings for a user
@@ -5307,6 +5944,12 @@ paths:
schema:
type: string
example: en
+ - in: query
+ name: type
+ required: false
+ schema:
+ type: string
+ example: 'Book'
responses:
'200':
description: Results
@@ -5411,6 +6054,58 @@ paths:
type: array
items:
$ref: '#/components/schemas/Company'
+
+ /discover/books:
+ get:
+ summary: Discover books
+ description: Returns a list of books in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: sortBy
+ schema:
+ type: string
+ - in: query
+ name: genre
+ schema:
+ type: string
+ - in: query
+ name: primaryReleaseDateGte
+ schema:
+ type: string
+ - in: query
+ name: primaryReleaseDateLte
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/BookResult'
+
/discover/movies:
get:
summary: Discover movies
@@ -6338,7 +7033,7 @@ paths:
name: mediaType
schema:
type: string
- enum: [movie, tv, all]
+ enum: [movie, tv, book, all]
nullable: true
default: all
responses:
@@ -6372,7 +7067,7 @@ paths:
properties:
mediaType:
type: string
- enum: [movie, tv]
+ enum: [movie, tv, book]
example: movie
mediaId:
type: number
@@ -6395,6 +7090,8 @@ paths:
type: number
profileId:
type: number
+ metadataProfileId:
+ type: number
rootFolder:
type: string
languageProfileId:
@@ -6402,6 +7099,10 @@ paths:
userId:
type: number
nullable: true
+ ignoreQuota:
+ type: boolean
+ example: false
+ description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission.
required:
- mediaType
- mediaId
@@ -6488,7 +7189,7 @@ paths:
properties:
mediaType:
type: string
- enum: [movie, tv]
+ enum: [movie, tv, book]
seasons:
type: array
items:
@@ -6501,6 +7202,8 @@ paths:
type: number
profileId:
type: number
+ metadataProfileId:
+ type: number
rootFolder:
type: string
languageProfileId:
@@ -6517,6 +7220,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MediaRequest'
+ '409':
+ description: Only pending requests can be modified
delete:
summary: Delete request
description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed.
@@ -6557,6 +7262,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MediaRequest'
+ '409':
+ description: Only failed requests can be retried
/request/{requestId}/{status}:
post:
summary: Update a request's status
@@ -6588,6 +7295,30 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MediaRequest'
+ '400':
+ description: Status must be approve or decline
+ '409':
+ description: Only pending requests can be approved or declined
+ /book/{bookId}:
+ get:
+ summary: Get book details
+ description: Returns full book details in a JSON object.
+ tags:
+ - books
+ parameters:
+ - in: path
+ name: bookId
+ required: true
+ schema:
+ type: number
+ example: 337401
+ responses:
+ '200':
+ description: Book details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BookDetails'
/movie/{movieId}:
get:
summary: Get movie details
@@ -6984,6 +7715,26 @@ paths:
criticsRating:
type: string
enum: ['Rotten', 'Fresh']
+ /author/{authorId}:
+ get:
+ summary: Get author details
+ description: Returns author details based on provided authorId in a JSON object.
+ tags:
+ - person
+ parameters:
+ - in: path
+ name: authorId
+ required: true
+ schema:
+ type: number
+ example: 287
+ responses:
+ '200':
+ description: Returned author
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthorDetails'
/person/{personId}:
get:
summary: Get person details
@@ -7119,7 +7870,7 @@ paths:
/media/{mediaId}/file:
delete:
summary: Delete media file
- description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action.
+ description: Removes a media file from radarr/sonarr/readarr. The `ADMIN` permission is required to perform this action.
tags:
- media
parameters:
@@ -7132,7 +7883,7 @@ paths:
type: string
- in: query
name: is4k
- description: Whether to remove from 4K service instance (true) or regular service instance (false)
+ description: Whether to remove from 4K/audiobook service instance (true) or regular service instance (false)
required: false
example: false
schema:
@@ -7140,6 +7891,42 @@ paths:
responses:
'204':
description: Successfully removed media item
+ /media/lookup/{id}:
+ get:
+ summary: Lookup internal media ID by external ID
+ description: |
+ Returns the internal media ID for a given external identifier (TMDB ID for movies/TV, Hardcover ID for books).
+ tags:
+ - media
+ parameters:
+ - in: path
+ name: id
+ description: tmdbId ID or hardcover ID
+ required: true
+ schema:
+ type: string
+ - in: query
+ name: mediaType
+ description: movie, tv, or book
+ required: true
+ schema:
+ type: string
+ enum: [movie, tv, book]
+ responses:
+ '200':
+ description: Internal media ID lookup result
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: number
+ nullable: true
+ description: Internal media ID, or null if not found
+ example: 123
+ '500':
+ description: Internal server error
/media/{mediaId}/{status}:
post:
summary: Update media status
@@ -7258,6 +8045,26 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Collection'
+ /series/{seriesId}:
+ get:
+ summary: Get series details
+ description: Returns full series details in a JSON object.
+ tags:
+ - series
+ parameters:
+ - in: path
+ name: seriesId
+ required: true
+ schema:
+ type: number
+ example: 537982
+ responses:
+ '200':
+ description: Series details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Series'
/service/radarr:
get:
summary: Get non-sensitive Radarr server list
@@ -7360,6 +8167,47 @@ paths:
type: array
items:
$ref: '#/components/schemas/SonarrSeries'
+ /service/readarr:
+ get:
+ summary: Get non-sensitive Readarr server list
+ description: Returns a list of Readarr server IDs and names in a JSON object.
+ tags:
+ - service
+ responses:
+ '200':
+ description: Request successful
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ReadarrSettings'
+ /service/readarr/{readarrId}:
+ get:
+ summary: Get Readarr server quality profiles, metadata profiles and root folders
+ description: Returns a Readarr server's quality profile, metadata profiles and root folder details in a JSON object.
+ tags:
+ - service
+ parameters:
+ - in: path
+ name: readarrId
+ required: true
+ schema:
+ type: number
+ example: 0
+ responses:
+ '200':
+ description: Request successful
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ server:
+ $ref: '#/components/schemas/ReadarrSettings'
+ profiles:
+ $ref: '#/components/schemas/ServiceProfile'
+
/regions:
get:
summary: Regions supported by TMDB
@@ -7590,6 +8438,9 @@ paths:
type: string
mediaId:
type: number
+ userId:
+ type: number
+ nullable: true
responses:
'201':
description: Succesfully created the issue
@@ -8014,6 +8865,70 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
+ /overrideRule/advancedRequest:
+ post:
+ summary: Advanced override rule request
+ description: Processes an advanced override rule request.
+ tags:
+ - overriderule
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - mediaType
+ - tmdbId
+ properties:
+ mediaType:
+ type: string
+ tmdbId:
+ type: number
+ example: 1
+ is4k:
+ type: boolean
+ example: false
+ requestId:
+ type: number
+ nullable: true
+ requestUser:
+ type: number
+ nullable: true
+ tags:
+ type: array
+ items:
+ type: number
+ nullable: true
+ serviceId:
+ type: number
+ nullable: true
+ responses:
+ '200':
+ description: Advanced override rule request processed
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ rootFolder:
+ type: string
+ nullable: true
+ profileId:
+ type: number
+ nullable: true
+ tags:
+ type: array
+ items:
+ type: number
+ nullable: true
+ '403':
+ description: User does not have permission to modify the request user
+ '404':
+ description: User, media or request not found
+ '500':
+ description: Unable to evaluate override rules
+
security:
- cookieAuth: []
- apiKey: []
diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts
index db9addcdbc..185c49dc65 100644
--- a/server/api/externalapi.ts
+++ b/server/api/externalapi.ts
@@ -1,8 +1,9 @@
-import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
+import type { CacheStore } from '@server/lib/cache';
+import { proxyRequestInterceptor } from '@server/utils/customProxyAgent';
+import { userAgentRequestInterceptor } from '@server/utils/userAgent';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
-import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
@@ -11,7 +12,7 @@ const DEFAULT_TTL = 300;
const DEFAULT_ROLLING_BUFFER = 10000;
export interface ExternalAPIOptions {
- nodeCache?: NodeCache;
+ nodeCache?: CacheStore;
headers?: Record;
timeout?: number;
rateLimit?: {
@@ -23,7 +24,7 @@ export interface ExternalAPIOptions {
class ExternalAPI {
protected axios: AxiosInstance;
private baseUrl: string;
- private cache?: NodeCache;
+ private cache?: CacheStore;
constructor(
baseUrl: string,
@@ -40,7 +41,8 @@ class ExternalAPI {
...options.headers,
},
});
- this.axios.interceptors.request.use(requestInterceptorFunction);
+ this.axios.interceptors.request.use(proxyRequestInterceptor);
+ this.axios.interceptors.request.use(userAgentRequestInterceptor);
if (options.rateLimit) {
this.axios = rateLimit(this.axios, {
@@ -53,27 +55,33 @@ class ExternalAPI {
this.cache = options.nodeCache;
}
+ // transform runs before the cache write.
protected async get(
endpoint: string,
config?: AxiosRequestConfig,
- ttl?: number
+ ttl?: number,
+ options?: { cache?: CacheStore; transform?: (data: T) => T }
): Promise {
+ const cache = options?.cache ?? this.cache;
const cacheKey = this.serializeCacheKey(endpoint, {
...config?.params,
headers: config?.headers,
});
- const cachedItem = this.cache?.get(cacheKey);
+ const cachedItem = cache?.get(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get(endpoint, config);
+ const data = options?.transform
+ ? options.transform(response.data)
+ : response.data;
- if (this.cache && ttl !== 0) {
- this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
+ if (cache && ttl !== 0) {
+ cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
- return response.data;
+ return data;
}
protected async post(
diff --git a/server/api/hardcover/constants.ts b/server/api/hardcover/constants.ts
new file mode 100644
index 0000000000..ee216632d4
--- /dev/null
+++ b/server/api/hardcover/constants.ts
@@ -0,0 +1,124 @@
+export const VALID_BOOK_HEADERS = [
+ 'bookstore',
+ 'adventure',
+ 'classics',
+ 'dystopian',
+ 'fantasy',
+ 'fiction',
+ 'history',
+ 'lgbtq',
+ 'mystery',
+ 'nonfiction',
+ 'science-fiction',
+ 'war',
+ 'young-adult',
+];
+
+export const BOOK = `
+ id
+ pages
+ title
+ subtitle
+ headline
+ description
+ users_count
+ release_date
+ rating
+ ratings_count
+ reviews_count
+ slug
+ cached_tags
+ image {
+ url
+ height
+ width
+ }
+ default_physical_edition_id
+ default_ebook_edition_id
+ default_audio_edition_id
+ default_physical_edition {
+ id
+ pages
+ isbn_10
+ isbn_13
+ asin
+ physical_format
+ publisher {
+ name
+ }
+ language {
+ language
+ }
+ }
+ default_ebook_edition {
+ id
+ isbn_13
+ asin
+ }
+ default_audio_edition {
+ id
+ asin
+ audio_seconds
+ contributions(where: { contribution: { _eq: "Narrator" } }, limit: 3) {
+ author {
+ name
+ }
+ }
+ }
+`;
+
+export const BOOK_RESULT = `
+ id
+ title
+ description
+ release_date
+ cached_tags
+ image {
+ url
+ height
+ width
+ }
+`;
+
+export const SERIES = `
+ name
+ id
+ slug
+ description
+ books_count
+ primary_books_count
+ book_series {
+ book {
+ ${BOOK_RESULT}
+ }
+ }
+`;
+
+export const AUTHOR = `
+ bio
+ books_count
+ id
+ identifiers
+ name
+ user_id
+ users_count
+ born_date
+ image {
+ url
+ height
+ width
+ }
+`;
+
+export const BOOK_SERIES = `
+ book_series(
+ order_by: [{position: asc}, {book: {users_count: desc}}]
+ distinct_on: position
+ where: {book: {book_status_id: {_eq: "1"}, compilation: {_eq: false}, release_date: {_is_null: false}}, position: {_gte: "0"}}
+ ) {
+ series_id
+ position
+ book {
+ ${BOOK_RESULT}
+ }
+ }`;
diff --git a/server/api/hardcover/hardcover.test.ts b/server/api/hardcover/hardcover.test.ts
new file mode 100644
index 0000000000..13696a4809
--- /dev/null
+++ b/server/api/hardcover/hardcover.test.ts
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import type { AxiosAdapter, AxiosInstance } from 'axios';
+import axios, { AxiosError } from 'axios';
+
+import Hardcover, { retryOnRateLimit } from '@server/api/hardcover';
+
+function statusSequence(statuses: number[], retryAfter = '0.01') {
+ let calls = 0;
+ const adapter: AxiosAdapter = async (config) => {
+ const status = statuses[Math.min(calls, statuses.length - 1)];
+ calls++;
+ const response = {
+ data: { ok: status === 200 },
+ status,
+ statusText: String(status),
+ headers: { 'retry-after': retryAfter },
+ config,
+ };
+
+ if (status !== 200) {
+ throw new AxiosError(
+ `Request failed with status code ${status}`,
+ AxiosError.ERR_BAD_REQUEST,
+ config,
+ null,
+ response
+ );
+ }
+
+ return response;
+ };
+
+ return { adapter, calls: () => calls };
+}
+
+describe('Hardcover rate limiting', () => {
+ it('retries a request that Hardcover rate limited', async () => {
+ const sequence = statusSequence([429, 200]);
+ const instance = axios.create({ adapter: sequence.adapter });
+ retryOnRateLimit(instance);
+
+ const response = await instance.post('/', {});
+
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(sequence.calls(), 2);
+ });
+
+ it('gives up after two retries', async () => {
+ const sequence = statusSequence([429]);
+ const instance = axios.create({ adapter: sequence.adapter });
+ retryOnRateLimit(instance);
+
+ await assert.rejects(() => instance.post('/', {}), /status code 429/);
+ assert.strictEqual(sequence.calls(), 3);
+ });
+
+ it('does not retry other errors', async () => {
+ const sequence = statusSequence([500]);
+ const instance = axios.create({ adapter: sequence.adapter });
+ retryOnRateLimit(instance);
+
+ await assert.rejects(() => instance.post('/', {}), /status code 500/);
+ assert.strictEqual(sequence.calls(), 1);
+ });
+
+ it('shares one rate-limited client between instances', () => {
+ const first = (new Hardcover() as unknown as { axios: AxiosInstance })
+ .axios;
+ const second = (new Hardcover() as unknown as { axios: AxiosInstance })
+ .axios;
+
+ assert.strictEqual(first, second);
+ });
+});
diff --git a/server/api/hardcover/index.ts b/server/api/hardcover/index.ts
new file mode 100644
index 0000000000..39dd716555
--- /dev/null
+++ b/server/api/hardcover/index.ts
@@ -0,0 +1,483 @@
+import ExternalAPI from '@server/api/externalapi';
+import {
+ AUTHOR,
+ BOOK,
+ BOOK_RESULT,
+ SERIES,
+ VALID_BOOK_HEADERS,
+} from '@server/api/hardcover/constants';
+import cacheManager from '@server/lib/cache';
+import { getSettings } from '@server/lib/settings';
+import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
+
+import type {
+ AuthorByPKResponse,
+ BookByPKResponse,
+ BookResponse,
+ Books,
+ BookTrendingResponse,
+ HardcoverAuthorDetails,
+ HardcoverAuthorResult,
+ HardcoverBookDetails,
+ HardcoverBookResult,
+ HardcoverSearchHit,
+ HardcoverSearchMultiResponse,
+ HardcoverSearchResponse,
+ HardcoverSeries,
+ SeriesByPKResponse,
+ Tags,
+} from './interfaces';
+
+export const BookSortOptionsIterable = [
+ 'popularity.desc',
+ 'popularity.asc',
+ 'release_date.desc',
+ 'release_date.asc',
+ 'original_title.asc',
+ 'original_title.desc',
+] as const;
+
+export type BookSortOptions = (typeof BookSortOptionsIterable)[number];
+
+interface DiscoverBookOptions {
+ genres?: string[];
+ releaseDateGte?: string;
+ releaseDateLte?: string;
+ sortBy?: BookSortOptions;
+ page?: number;
+}
+
+interface SearchOptions {
+ query?: string;
+ page?: number;
+}
+
+export const getHeader = (tags: Tags): string | null => {
+ if (!tags.Genre?.length) {
+ return null;
+ }
+
+ for (const genre of tags.Genre) {
+ if (genre.tagSlug && VALID_BOOK_HEADERS.includes(genre.tagSlug)) {
+ return genre.tagSlug;
+ }
+ }
+
+ return null;
+};
+
+const RATE_LIMIT_RETRIES = 2;
+const RATE_LIMIT_MAX_WAIT_MS = 10_000;
+
+type RetryableConfig = InternalAxiosRequestConfig & {
+ hardcoverRetries?: number;
+};
+
+export const retryOnRateLimit = (instance: AxiosInstance): void => {
+ instance.interceptors.response.use(undefined, async (error) => {
+ const config = error?.config as RetryableConfig | undefined;
+
+ if (error?.response?.status !== 429 || !config) {
+ throw error;
+ }
+
+ const attempt = (config.hardcoverRetries ?? 0) + 1;
+
+ if (attempt > RATE_LIMIT_RETRIES) {
+ throw error;
+ }
+
+ config.hardcoverRetries = attempt;
+
+ const retryAfterSeconds = Number(error.response.headers?.['retry-after']);
+ const waitMs =
+ Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
+ ? Math.min(retryAfterSeconds * 1000, RATE_LIMIT_MAX_WAIT_MS)
+ : attempt * 2000;
+
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
+
+ return instance.request(config);
+ });
+};
+
+// Routes create a client per request, so the limiter has to be shared to keep
+// the server inside Hardcover's 60 requests a minute.
+let sharedClient: { token: string; axios: AxiosInstance } | undefined;
+
+class Hardcover extends ExternalAPI {
+ constructor() {
+ const token = getSettings().main.hardcoverapikey;
+ super(
+ 'https://api.hardcover.app/v1/graphql',
+ {},
+ {
+ nodeCache: cacheManager.getCache('hardcover').data,
+ rateLimit: {
+ maxRPS: 1,
+ maxRequests: 50,
+ },
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ if (!sharedClient || sharedClient.token !== token) {
+ retryOnRateLimit(this.axios);
+ sharedClient = { token, axios: this.axios };
+ }
+
+ this.axios = sharedClient.axios;
+ }
+
+ private getTrendingBooks = async (offset = 0): Promise => {
+ try {
+ const now = new Date();
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ const fromDate = thirtyDaysAgo.toISOString().split('T')[0];
+ const toDate = new Date().toISOString().split('T')[0];
+
+ const data = await this.post('/', {
+ query: `
+ query Books_trending($fromDate: date!, $toDate: date!, $offset: Int!) {
+ books_trending(from: $fromDate, to: $toDate, offset: $offset, limit: 50) {
+ ids
+ }
+ }
+ `,
+ variables: {
+ fromDate,
+ toDate,
+ offset,
+ },
+ });
+
+ if (!data || !data.data || !data.data.books_trending) {
+ throw new Error(
+ `Invalid API response structure: ${JSON.stringify(data)}`
+ );
+ }
+
+ const bookIds = data.data.books_trending.ids;
+
+ if (!Array.isArray(bookIds)) {
+ throw new Error(
+ `Expected books_trending.ids to be an array, got: ${typeof bookIds}`
+ );
+ }
+
+ return bookIds;
+ } catch (error) {
+ throw new Error(`Error fetching trending books: ${error.message}`);
+ }
+ };
+
+ private createMultiSearchResults = (
+ hits: HardcoverSearchHit[]
+ ): (HardcoverBookResult | HardcoverAuthorResult)[] => {
+ const results = [];
+ const seenAuthors = new Set();
+
+ for (const hit of hits) {
+ for (const contrib of hit.document.contributions || []) {
+ if (
+ contrib.contribution === null &&
+ contrib.author?.id &&
+ !seenAuthors.has(contrib.author.id)
+ ) {
+ results.push({ ...contrib.author, media_type: 'author' });
+ seenAuthors.add(contrib.author.id);
+ }
+ }
+
+ if (!hit.document.compilation) {
+ results.push({
+ ...hit.document,
+ id: +hit.document.id,
+ media_type: 'book',
+ });
+ }
+ }
+
+ return results as (HardcoverBookResult | HardcoverAuthorResult)[];
+ };
+
+ public search = async ({
+ query,
+ page = 1,
+ }: SearchOptions = {}): Promise => {
+ try {
+ const searchData = await this.post('/', {
+ query: `
+ query Search($query: String!, $page: Int!) {
+ search(
+ query: $query
+ page: $page
+ per_page: 25
+ query_type: "book"
+ fields: "title,isbns,series_names,author_names,alternative_titles"
+ weights: "5,1,3,5,1"
+ sort: "ratings_count:desc,_text_match:desc"
+ ) {
+ results
+ }
+ }
+ `,
+ variables: {
+ query: query,
+ page: page,
+ },
+ });
+
+ const hits = searchData.data.search.results.hits || [];
+
+ const results = this.createMultiSearchResults(hits);
+
+ const total_results = searchData.data.search.results.found || 0;
+ const per_page = 25;
+
+ return {
+ results,
+ page: page,
+ total_results: total_results,
+ total_pages: Math.ceil(total_results / per_page),
+ };
+ } catch (e) {
+ throw new Error(
+ `[HARDCOVER] Failed to fetch search results: ${e.message}`
+ );
+ }
+ };
+
+ public getDiscoverBooks = async ({
+ page = 1,
+ genres,
+ releaseDateGte,
+ releaseDateLte,
+ sortBy,
+ }: DiscoverBookOptions = {}): Promise => {
+ try {
+ const hasFilters = !!(
+ genres?.length ||
+ releaseDateGte ||
+ releaseDateLte ||
+ (sortBy && sortBy !== 'popularity.desc')
+ );
+
+ if (!hasFilters) {
+ const offset = Math.floor((page - 1) * 50);
+
+ const ids = await this.getTrendingBooks(offset);
+
+ return {
+ data: await this.getBooks(ids),
+ page: page,
+ total_results: 10000,
+ total_pages: 200,
+ };
+ }
+
+ const limit = 20;
+ const offset = (page - 1) * limit;
+
+ const slugPattern = /^[a-z0-9-]+$/;
+ const datePattern = /^\d{4}-\d{2}-\d{2}$/;
+
+ const conditions: string[] = [
+ '{book_status_id: {_eq: "1"}}',
+ '{compilation: {_eq: false}}',
+ ];
+ (genres ?? [])
+ .filter((genre) => slugPattern.test(genre))
+ .forEach((genre) => {
+ conditions.push(
+ `{cached_tags: {_contains: {Genre: [{tagSlug: "${genre}"}]}}}`
+ );
+ });
+ if (releaseDateGte && datePattern.test(releaseDateGte)) {
+ conditions.push(`{release_date: {_gte: "${releaseDateGte}"}}`);
+ }
+ if (releaseDateLte && datePattern.test(releaseDateLte)) {
+ conditions.push(`{release_date: {_lte: "${releaseDateLte}"}}`);
+ } else if (sortBy === 'release_date.desc') {
+ const maxDate = new Date();
+ maxDate.setFullYear(maxDate.getFullYear() + 1);
+ conditions.push(
+ `{release_date: {_lte: "${maxDate.toISOString().split('T')[0]}"}}`
+ );
+ }
+
+ const orderMap: Record = {
+ 'popularity.asc': '{users_count: asc}',
+ 'popularity.desc': '{users_count: desc}',
+ 'release_date.asc': '{release_date: asc}',
+ 'release_date.desc': '{release_date: desc}',
+ 'original_title.asc': '{title: asc}',
+ 'original_title.desc': '{title: desc}',
+ };
+
+ const data = await this.post('/', {
+ query: `
+ query DiscoverBooksFiltered($limit: Int!, $offset: Int!) {
+ books(
+ where: {_and: [${conditions.join(', ')}]}
+ order_by: ${orderMap[sortBy ?? 'popularity.desc']}
+ limit: $limit
+ offset: $offset
+ ) {
+ ${BOOK_RESULT}
+ }
+ }
+ `,
+ variables: {
+ limit,
+ offset,
+ },
+ });
+
+ return {
+ data: { books: data.data.books },
+ page,
+ total_results: 1000,
+ total_pages: 50,
+ };
+ } catch (e) {
+ throw new Error(
+ `[HARDCOVER] Failed to fetch discover books: ${e.message}`
+ );
+ }
+ };
+
+ public getAuthor = async (
+ authorId: number
+ ): Promise => {
+ try {
+ const data = await this.post('/', {
+ query: `
+ query Authors_by_pk($authorId: Int!) {
+ authors_by_pk(id: $authorId) {
+ ${AUTHOR}
+ contributions(
+ where: {book: {book_status_id: {_eq: "1"}, compilation: {_eq: false}, default_physical_edition: {language_id: {_eq: 1}}}, _or: [{contribution: {_is_null: true}}, {contribution: {_nin: ["Narrator", "Translator", "Illustrator"]}}]}
+ order_by: {book: {users_count: desc}}
+ ) {
+ id
+ book {
+ ${BOOK_RESULT}
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ authorId,
+ },
+ });
+
+ return data.data.authors_by_pk;
+ } catch (e) {
+ throw new Error(
+ `[HARDCOVER] Failed to fetch author details: ${e.message}`
+ );
+ }
+ };
+
+ public getBooks = async (bookIds: number[]): Promise => {
+ try {
+ const data = await this.post('/', {
+ query: `
+ query Books($bookIds: [Int!]!) {
+ books(where: { id: { _in: $bookIds } }) {
+ ${BOOK_RESULT}
+ }
+ }
+ `,
+ variables: {
+ bookIds,
+ },
+ });
+
+ const bookMap = new Map(data.data.books.map((book) => [book.id, book]));
+
+ const orderedBooks = bookIds
+ .map((id) => bookMap.get(id))
+ .filter((book): book is HardcoverBookResult => book !== undefined);
+
+ return { books: orderedBooks };
+ } catch (e) {
+ throw new Error(`[HARDCOVER] Failed to fetch book details: ${e.message}`);
+ }
+ };
+
+ public getBook = async (bookId: number): Promise => {
+ try {
+ const data = await this.post('/', {
+ query: `
+ query Books($bookId: Int!) {
+ books_by_pk(id: $bookId ) {
+ ${BOOK}
+ contributions(order_by: {author: {users_count: desc}}, where: {_or: [{contribution: {_is_null: true}}, {contribution: {_nin: ["Narrator", "Translator", "Illustrator"]}}]}) {
+ author {
+ ${AUTHOR}
+ }
+ }
+ book_series(order_by: {featured: desc}) {
+ position
+ series_id
+ series {
+ ${SERIES}
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ bookId,
+ },
+ });
+
+ return data.data.books_by_pk;
+ } catch (e) {
+ throw new Error(`[HARDCOVER] Failed to fetch book details: ${e.message}`);
+ }
+ };
+
+ public getSeries = async (seriesId: number): Promise => {
+ try {
+ const data = await this.post('/', {
+ query: `
+ query Series($seriesId: Int!) {
+ series_by_pk(id: $seriesId) {
+ id
+ name
+ description
+ primary_books_count
+ books_count
+ book_series(
+ order_by: [{position: asc}, {book: {users_count: desc}}]
+ distinct_on: position
+ where: {book: {book_status_id: {_eq: "1"}, compilation: {_eq: false}, release_date: {_is_null: false}}, position: {_gt: "0"}}
+ ) {
+ series_id
+ position
+ book {
+ ${BOOK_RESULT}
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ seriesId,
+ },
+ });
+
+ return data.data.series_by_pk;
+ } catch (e) {
+ throw new Error(`[HARDCOVER] Failed to fetch book details: ${e.message}`);
+ }
+ };
+}
+
+export default Hardcover;
diff --git a/server/api/hardcover/interfaces.ts b/server/api/hardcover/interfaces.ts
new file mode 100644
index 0000000000..9cfd64e261
--- /dev/null
+++ b/server/api/hardcover/interfaces.ts
@@ -0,0 +1,334 @@
+export interface HardcoverPaginatedResponse {
+ page: number;
+ total_results: number;
+ total_pages: number;
+}
+
+export interface Image {
+ color?: string;
+ color_name?: string;
+ height: number;
+ id: number;
+ url: string;
+ width: number;
+}
+
+export interface Author {
+ alternate_names: string[];
+ books: string[];
+ books_count: number;
+ id: number;
+ image?: Image;
+ name: string;
+ name_personal: string;
+ series_names: string[];
+ slug: string;
+}
+
+export interface Book {
+ id: number;
+ title: string;
+ description: string;
+ release_date: string;
+ tags: Tags;
+ image: Image;
+ book_series: {
+ position: number;
+ series_id: number;
+ series: Series;
+ }[];
+}
+
+export interface Series {
+ name: string;
+ id: number;
+ slug: string;
+ description: string;
+ books_count: number;
+ primary_books_count: number;
+}
+
+export interface HardcoverBookResult {
+ id: number;
+ title: string;
+ description: string;
+ release_date: string;
+ cached_tags?: Tags;
+ image?: Image;
+ media_type: 'book';
+}
+
+export interface HardcoverAuthorResult {
+ id: number;
+ name: string;
+ image?: Image;
+ slug: string;
+ media_type: 'author';
+}
+
+export interface BookTrendingResponse {
+ data: {
+ books_trending: {
+ ids: number[];
+ };
+ };
+}
+
+export interface HardcoverSearchMultiResponse extends HardcoverPaginatedResponse {
+ results: (HardcoverBookResult | HardcoverAuthorResult)[];
+}
+
+export interface AuthorByPKResponse {
+ data: {
+ authors_by_pk: HardcoverAuthorDetails;
+ };
+}
+
+export interface BookByPKResponse {
+ data: {
+ books_by_pk: HardcoverBookDetails;
+ };
+}
+
+export interface SeriesByPKResponse {
+ data: {
+ series_by_pk: HardcoverSeries;
+ };
+}
+
+export interface RelatedResponse {
+ data: {
+ recommendations: {
+ item_book: HardcoverBookResult;
+ }[];
+ };
+}
+
+export interface AuthorResponse extends HardcoverPaginatedResponse {
+ data: {
+ authors: Author[];
+ };
+}
+
+export interface BookResponse extends HardcoverPaginatedResponse {
+ data: Books;
+}
+
+export interface Books {
+ books: HardcoverBookResult[];
+}
+
+export interface AuthorContribution {
+ id: number;
+ book: HardcoverBookResult;
+}
+
+export interface BookContribution {
+ id: number;
+ author: Author;
+ contribution?: string | null;
+}
+
+export interface Identifiers {
+ goodreads: string[];
+ openlibrary: string[];
+ isbn_10: string[];
+ isbn_13: string[];
+ asin: string[];
+ kindle_asin: string[];
+ inventaire_id: string[];
+ lccn: string[];
+ ocaid: string[];
+ oclc: string[];
+}
+
+export interface Tags {
+ Genre: {
+ tag: string;
+ tagSlug: string;
+ category: string;
+ categorySlug: string;
+ spoilerRatio: number;
+ count: number;
+ }[];
+ Mood: {
+ tag: string;
+ tagSlug: string;
+ category: string;
+ categorySlug: string;
+ spoilerRatio: number;
+ count: number;
+ }[];
+ 'Content Warning': {
+ tag: string;
+ tagSlug: string;
+ category: string;
+ categorySlug: string;
+ spoilerRatio: number;
+ count: number;
+ }[];
+ Tag: {
+ tag: string;
+ tagSlug: string;
+ category: string;
+ categorySlug: string;
+ spoilerRatio: number;
+ count: number;
+ }[];
+}
+
+export interface HardcoverSearchHit {
+ document: {
+ activities_count: number;
+ alternative_titles: string[];
+ author_names: string[];
+ compilation: boolean;
+ content_warnings: string[];
+ contribution_types: string[];
+ contributions: BookContribution[];
+ cover_color?: string;
+ description?: string;
+ featured_series: {
+ details: string;
+ featured: boolean;
+ id: number;
+ position: number;
+ series: Series;
+ unreleased: boolean;
+ };
+ genres: string[];
+ has_audiobook: boolean;
+ has_ebook: boolean;
+ id: string;
+ image: Image;
+ isbns: string[];
+ lists_count: number;
+ moods: string[];
+ pages?: number;
+ prompts_count: number;
+ rating: number;
+ ratings_count: number;
+ release_date?: string;
+ release_year?: number;
+ reviews_count: number;
+ series_names: string[];
+ slug: string;
+ subtitle?: string;
+ tags: string[];
+ title: string;
+ users_count: number;
+ users_read_count: number;
+ };
+ text_match: number;
+ text_match_info: {
+ best_field_score: string;
+ best_field_weight: number;
+ fields_matched: number;
+ score: string;
+ tokens_matched: number;
+ };
+}
+
+export interface HardcoverSearchResponse {
+ data: {
+ search: {
+ results: {
+ found: number;
+ hits: HardcoverSearchHit[];
+ page: number;
+ request_params: {
+ per_page: number;
+ q: string;
+ };
+ };
+ };
+ };
+}
+
+interface BookSeries {
+ position: number;
+ book: HardcoverBookResult;
+}
+
+export interface HardcoverSeries {
+ id: number;
+ name: string;
+ description: string;
+ primary_books_count: number;
+ books_count: number;
+ book_series: BookSeries[];
+}
+
+export interface HardcoverPhysicalEdition {
+ id: number;
+ pages?: number;
+ isbn_10?: string;
+ isbn_13?: string;
+ asin?: string;
+ physical_format?: string;
+ publisher?: {
+ name: string;
+ };
+ language?: {
+ language: string;
+ };
+}
+
+export interface HardcoverEbookEdition {
+ id: number;
+ isbn_13?: string;
+ asin?: string;
+}
+
+export interface HardcoverAudioEdition {
+ id: number;
+ asin?: string;
+ audio_seconds?: number;
+ contributions?: {
+ author: {
+ name: string;
+ };
+ }[];
+}
+
+export interface HardcoverBookDetails {
+ id: number;
+ title: string;
+ description: string;
+ release_date: string;
+ image: Image;
+ subtitle: string;
+ headline: string;
+ rating: number;
+ ratings_count: number;
+ reviews_count: number;
+ slug: string;
+ pages: number;
+ cached_tags: Tags;
+ contributions: BookContribution[];
+ book_series: {
+ position: number;
+ series_id: number;
+ series: HardcoverSeries;
+ }[];
+ default_physical_edition_id?: number;
+ default_ebook_edition_id?: number;
+ default_audio_edition_id?: number;
+ default_physical_edition?: HardcoverPhysicalEdition;
+ default_ebook_edition?: HardcoverEbookEdition;
+ default_audio_edition?: HardcoverAudioEdition;
+}
+
+export interface HardcoverAuthorDetails {
+ bio: string;
+ books_count: number;
+ id: number;
+ identifiers: Identifiers;
+ name: string;
+ image: {
+ url: string;
+ };
+ users_count: number;
+ born_date: string;
+ contributions: AuthorContribution[];
+}
diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts
index 28afc3e0ee..188beb1ff1 100644
--- a/server/api/jellyfin.ts
+++ b/server/api/jellyfin.ts
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
AccessToken: string;
}
+export interface QuickConnectInitiateResponse {
+ Secret: string;
+ Code: string;
+ DateAdded: string;
+}
+
+export interface QuickConnectStatusResponse {
+ Authenticated: boolean;
+ Secret: string;
+ Code: string;
+ DeviceId: string;
+ DeviceName: string;
+ AppName: string;
+ AppVersion: string;
+ DateAdded: string;
+}
+
export interface JellyfinUserListResponse {
users: JellyfinUserResponse[];
}
@@ -216,7 +233,79 @@ class JellyfinAPI extends ExternalAPI {
}
);
- throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.Unknown);
+ }
+ }
+
+ public async initiateQuickConnect(): Promise {
+ try {
+ const response = await this.post(
+ '/QuickConnect/Initiate'
+ );
+
+ return response;
+ } catch (e) {
+ logger.error(
+ `Something went wrong while initiating Quick Connect: ${e.message}`,
+ { label: 'Jellyfin API', error: e.response?.status }
+ );
+
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.Unknown);
+ }
+ }
+
+ public async checkQuickConnect(
+ secret: string
+ ): Promise {
+ try {
+ const response = await this.get(
+ '/QuickConnect/Connect',
+ { params: { secret } }
+ );
+
+ return response;
+ } catch (e) {
+ logger.error(
+ `Something went wrong while getting Quick Connect status: ${e.message}`,
+ { label: 'Jellyfin API', error: e.response?.status }
+ );
+
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.Unknown);
+ }
+ }
+
+ public async authenticateQuickConnect(
+ secret: string
+ ): Promise {
+ try {
+ const response = await this.post(
+ '/Users/AuthenticateWithQuickConnect',
+ { Secret: secret }
+ );
+ return response;
+ } catch (e) {
+ logger.error(
+ `Something went wrong while authenticating with Quick Connect: ${e.message}`,
+ { label: 'Jellyfin API', error: e.response?.status }
+ );
+
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.Unknown);
}
}
@@ -231,7 +320,11 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse;
} catch (e) {
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -248,7 +341,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.Unknown);
}
}
@@ -263,7 +360,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -279,7 +380,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -306,6 +411,10 @@ class JellyfinAPI extends ExternalAPI {
}
);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
return [];
}
}
@@ -352,7 +461,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e?.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -377,7 +490,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -404,7 +521,11 @@ class JellyfinAPI extends ExternalAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -419,7 +540,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -450,7 +575,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -467,7 +596,11 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API', error: e.response?.status }
);
- throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.InvalidAuthToken);
}
}
}
diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts
index 617203447f..0837569b03 100644
--- a/server/api/plexapi.ts
+++ b/server/api/plexapi.ts
@@ -1,7 +1,9 @@
import ExternalAPI from '@server/api/externalapi';
+import { ApiErrorCode } from '@server/constants/error';
import type { Library, PlexSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
+import { ApiError } from '@server/types/error';
interface PlexStatusResponse {
MediaContainer: {
@@ -148,7 +150,7 @@ class PlexAPI extends ExternalAPI {
.filter((library) => library.agent !== 'com.plexapp.agents.none')
.map((library) => {
const existing = settings.plex.libraries.find(
- (l) => l.id === library.key && l.name === library.title
+ (l) => l.id === library.key
);
return {
@@ -167,7 +169,11 @@ class PlexAPI extends ExternalAPI {
message: e.message,
});
- settings.plex.libraries = [];
+ if (!e.response) {
+ throw new ApiError(502, ApiErrorCode.ConnectionError);
+ }
+
+ throw new ApiError(e.response.status, ApiErrorCode.Unknown);
}
await settings.save();
@@ -222,8 +228,9 @@ class PlexAPI extends ExternalAPI {
mediaType: 'movie' | 'show'
): Promise {
const response = await this.get(
- `/library/sections/${id}/all?type=${
- mediaType === 'show' ? '4' : '1'
+ `/library/sections/${id}/all?type=${mediaType === 'show' ? '4' : '1'}${
+ // Shows are queried as episodes, whose guids the scanner never reads.
+ mediaType === 'movie' ? '&includeGuids=1' : ''
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
{
headers: {
diff --git a/server/api/plextv.test.ts b/server/api/plextv.test.ts
new file mode 100644
index 0000000000..4bfcf80a6f
--- /dev/null
+++ b/server/api/plextv.test.ts
@@ -0,0 +1,190 @@
+import PlexTvAPI, { WATCHLIST_METADATA_CONCURRENCY } from '@server/api/plextv';
+import cacheManager from '@server/lib/cache';
+import type {
+ AxiosAdapter,
+ AxiosResponse,
+ InternalAxiosRequestConfig,
+} from 'axios';
+import assert from 'node:assert/strict';
+import { afterEach, beforeEach, describe, it } from 'node:test';
+
+class TestPlexTvAPI extends PlexTvAPI {
+ public installAdapter(adapter: AxiosAdapter): void {
+ this.axios.defaults.adapter = adapter;
+ }
+}
+
+function axiosResponse(
+ config: InternalAxiosRequestConfig,
+ data: T,
+ headers: Record = {}
+): AxiosResponse {
+ return {
+ data,
+ status: 200,
+ statusText: 'OK',
+ headers,
+ config,
+ request: {},
+ };
+}
+
+function watchlistResponse(count: number) {
+ return {
+ MediaContainer: {
+ totalSize: count,
+ Metadata: Array.from({ length: count }, (_, index) => ({
+ ratingKey: `rk-${index + 1}`,
+ })),
+ },
+ };
+}
+
+function metadataResponse(ratingKey: string) {
+ const id = ratingKey.replace('rk-', '');
+
+ return {
+ MediaContainer: {
+ Metadata: [
+ {
+ ratingKey,
+ type: 'movie' as const,
+ title: `Movie ${id}`,
+ Guid: [{ id: `tmdb://${id}` as const }],
+ },
+ ],
+ },
+ };
+}
+
+function isMetadataRequest(url: string | undefined): boolean {
+ return !!url?.includes('/library/metadata/');
+}
+
+function isWatchlistRequest(url: string | undefined): boolean {
+ return !!url?.includes('/library/sections/watchlist/all');
+}
+
+describe('PlexTvAPI.getWatchlist metadata fetching', () => {
+ beforeEach(() => {
+ cacheManager.getCache('plexwatchlist').flush();
+ cacheManager.getCache('plextv').flush();
+ });
+
+ afterEach(() => {
+ cacheManager.getCache('plexwatchlist').flush();
+ cacheManager.getCache('plextv').flush();
+ });
+
+ it('limits concurrent metadata requests', async () => {
+ const itemCount = 12;
+ let inFlight = 0;
+ let maxInFlight = 0;
+
+ const api = new TestPlexTvAPI('test-token');
+ api.installAdapter(async (config) => {
+ const url = config.url;
+
+ if (isWatchlistRequest(url)) {
+ return axiosResponse(config, watchlistResponse(itemCount), {
+ etag: 'test-etag',
+ });
+ }
+
+ if (isMetadataRequest(url)) {
+ inFlight++;
+ maxInFlight = Math.max(maxInFlight, inFlight);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ inFlight--;
+
+ const ratingKey = url?.split('/library/metadata/')[1] ?? '';
+ return axiosResponse(config, metadataResponse(ratingKey));
+ }
+
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ const result = await api.getWatchlist({ size: itemCount });
+
+ assert.strictEqual(result.items.length, itemCount);
+ assert.ok(
+ maxInFlight <= WATCHLIST_METADATA_CONCURRENCY,
+ `expected at most ${WATCHLIST_METADATA_CONCURRENCY} concurrent metadata requests, saw ${maxInFlight}`
+ );
+ });
+
+ it('returns partial results when individual metadata requests fail', async () => {
+ const itemCount = 8;
+
+ const api = new TestPlexTvAPI('test-token');
+ api.installAdapter(async (config) => {
+ const url = config.url;
+
+ if (isWatchlistRequest(url)) {
+ return axiosResponse(config, watchlistResponse(itemCount), {
+ etag: 'test-etag',
+ });
+ }
+
+ if (isMetadataRequest(url)) {
+ const ratingKey = url?.split('/library/metadata/')[1] ?? '';
+
+ if (Number(ratingKey.replace('rk-', '')) % 2 === 0) {
+ const error = new Error('connect ETIMEDOUT') as Error & {
+ code: string;
+ };
+ error.code = 'ETIMEDOUT';
+ throw error;
+ }
+
+ return axiosResponse(config, metadataResponse(ratingKey));
+ }
+
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ const result = await api.getWatchlist({ size: itemCount });
+
+ assert.strictEqual(result.items.length, 4);
+ assert.ok(
+ result.items.every((item) => Number(item.tmdbId) % 2 === 1),
+ 'only odd-numbered watchlist items should be returned'
+ );
+ });
+
+ it('fails the sync when metadata requests return auth errors', async () => {
+ const itemCount = 3;
+
+ const api = new TestPlexTvAPI('test-token');
+ api.installAdapter(async (config) => {
+ const url = config.url;
+
+ if (isWatchlistRequest(url)) {
+ return axiosResponse(config, watchlistResponse(itemCount), {
+ etag: 'test-etag',
+ });
+ }
+
+ if (isMetadataRequest(url)) {
+ const error = new Error(
+ 'Request failed with status code 401'
+ ) as Error & {
+ response: { status: number };
+ };
+ error.response = { status: 401 };
+ throw error;
+ }
+
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ const result = await api.getWatchlist({ size: itemCount });
+
+ assert.deepStrictEqual(result, {
+ offset: 0,
+ size: itemCount,
+ totalSize: 0,
+ items: [],
+ });
+ });
+});
diff --git a/server/api/plextv.ts b/server/api/plextv.ts
index b8f3c9a47b..20df0a50b5 100644
--- a/server/api/plextv.ts
+++ b/server/api/plextv.ts
@@ -135,6 +135,38 @@ export interface PlexWatchlistCache {
response: WatchlistResponse;
}
+export const WATCHLIST_METADATA_CONCURRENCY = 5;
+
+async function mapWithConcurrency(
+ items: readonly T[],
+ concurrency: number,
+ fn: (item: T) => Promise
+): Promise {
+ if (items.length === 0) {
+ return [];
+ }
+
+ const results = new Array(items.length);
+ let nextIndex = 0;
+
+ const worker = async (): Promise => {
+ while (true) {
+ const index = nextIndex++;
+ if (index >= items.length) {
+ return;
+ }
+
+ results[index] = await fn(items[index]);
+ }
+ };
+
+ await Promise.all(
+ Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
+ );
+
+ return results;
+}
+
class PlexTvAPI extends ExternalAPI {
private authToken: string;
@@ -268,6 +300,70 @@ class PlexTvAPI extends ExternalAPI {
return parsedXml;
}
+ private async fetchWatchlistItemMetadata(watchlistItem: {
+ ratingKey: string;
+ }): Promise {
+ let detailedResponse: MetadataResponse;
+ try {
+ detailedResponse = await this.getRolling(
+ `/library/metadata/${watchlistItem.ratingKey}`,
+ {
+ baseURL: 'https://discover.provider.plex.tv',
+ }
+ );
+ } catch (e) {
+ if (e.response?.status === 404) {
+ logger.warn(
+ `Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
+ { label: 'Plex.TV Metadata API' }
+ );
+ return null;
+ }
+
+ if (e.code === 'ETIMEDOUT' || e.code === 'ENETUNREACH') {
+ logger.warn(
+ `Failed to fetch metadata for ratingKey ${watchlistItem.ratingKey}`,
+ {
+ label: 'Plex.TV Metadata API',
+ errorMessage: e.message,
+ }
+ );
+ return null;
+ }
+
+ throw e;
+ }
+
+ const metadata =
+ detailedResponse.MediaContainer.Metadata?.[0] ??
+ detailedResponse.MediaContainer.Video?.[0];
+
+ if (!metadata) {
+ logger.warn(
+ `Item with ratingKey ${watchlistItem.ratingKey} returned no metadata, skipping.`,
+ { label: 'Plex.TV Metadata API' }
+ );
+ return null;
+ }
+
+ const tmdbString = metadata.Guid?.find((guid) =>
+ guid.id.startsWith('tmdb')
+ );
+ const tvdbString = metadata.Guid?.find((guid) =>
+ guid.id.startsWith('tvdb')
+ );
+
+ return {
+ ratingKey: metadata.ratingKey,
+ // This should always be set? But I guess it also cannot be?
+ // We will filter out the 0's afterwards
+ tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
+ tvdbId: tvdbString ? Number(tvdbString.id.split('//')[1]) : undefined,
+ title: metadata.title,
+ type: metadata.type,
+ };
+ }
+
public async getWatchlist({
offset = 0,
size = 20,
@@ -311,61 +407,10 @@ class PlexTvAPI extends ExternalAPI {
);
}
- const watchlistDetails = await Promise.all(
- (cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
- async (watchlistItem) => {
- let detailedResponse: MetadataResponse;
- try {
- detailedResponse = await this.getRolling(
- `/library/metadata/${watchlistItem.ratingKey}`,
- {
- baseURL: 'https://discover.provider.plex.tv',
- }
- );
- } catch (e) {
- if (e.response?.status === 404) {
- logger.warn(
- `Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
- { label: 'Plex.TV Metadata API' }
- );
- return null;
- } else {
- throw e;
- }
- }
-
- const metadata =
- detailedResponse.MediaContainer.Metadata?.[0] ??
- detailedResponse.MediaContainer.Video?.[0];
-
- if (!metadata) {
- logger.warn(
- `Item with ratingKey ${watchlistItem.ratingKey} returned no metadata, skipping.`,
- { label: 'Plex.TV Metadata API' }
- );
- return null;
- }
-
- const tmdbString = metadata.Guid?.find((guid) =>
- guid.id.startsWith('tmdb')
- );
- const tvdbString = metadata.Guid?.find((guid) =>
- guid.id.startsWith('tvdb')
- );
-
- return {
- ratingKey: metadata.ratingKey,
- // This should always be set? But I guess it also cannot be?
- // We will filter out the 0's afterwards
- tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
- tvdbId: tvdbString
- ? Number(tvdbString.id.split('//')[1])
- : undefined,
- title: metadata.title,
- type: metadata.type,
- };
- }
- )
+ const watchlistDetails = await mapWithConcurrency(
+ cachedWatchlist?.response.MediaContainer.Metadata ?? [],
+ WATCHLIST_METADATA_CONCURRENCY,
+ (watchlistItem) => this.fetchWatchlistItemMetadata(watchlistItem)
);
const filteredList = watchlistDetails.filter(
diff --git a/server/api/servarr/radarr.test.ts b/server/api/servarr/radarr.test.ts
new file mode 100644
index 0000000000..f3aaed3848
--- /dev/null
+++ b/server/api/servarr/radarr.test.ts
@@ -0,0 +1,119 @@
+import assert from 'node:assert/strict';
+import { afterEach, describe, it, mock } from 'node:test';
+
+import type { AxiosInstance } from 'axios';
+
+import RadarrAPI from '@server/api/servarr/radarr';
+
+function buildRadarr(): RadarrAPI {
+ return new RadarrAPI({ url: 'http://localhost:7878/api/v3', apiKey: 'test' });
+}
+
+function getAxios(radarr: RadarrAPI): AxiosInstance {
+ return (radarr as unknown as { axios: AxiosInstance }).axios;
+}
+
+describe('RadarrAPI removeMovie', () => {
+ afterEach(() => mock.restoreAll());
+
+ it('removes the movie when it exists in the library', async () => {
+ const radarr = buildRadarr();
+ mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
+ id: 7,
+ title: 'Test Movie',
+ }));
+ const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
+
+ await radarr.removeMovie(550);
+
+ assert.strictEqual(del.mock.callCount(), 1);
+ assert.strictEqual(del.mock.calls[0].arguments[0], '/movie/7');
+ });
+
+ it('does nothing when the movie is not in the library', async () => {
+ const radarr = buildRadarr();
+ mock.method(getAxios(radarr), 'get', async () => ({
+ data: [{ id: 0, title: 'Fight Club' }],
+ }));
+ const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
+
+ await assert.doesNotReject(() => radarr.removeMovie(550));
+ assert.strictEqual(del.mock.callCount(), 0);
+ });
+
+ it('rejects when the tmdbId is unknown to the lookup', async () => {
+ const radarr = buildRadarr();
+ mock.method(getAxios(radarr), 'get', async () => ({ data: [] }));
+ const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
+
+ await assert.rejects(() => radarr.removeMovie(550), /Movie not found/);
+ assert.strictEqual(del.mock.callCount(), 0);
+ });
+
+ it('ignores a 404 when the movie was already removed in Radarr', async () => {
+ const radarr = buildRadarr();
+ mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
+ id: 7,
+ title: 'Test Movie',
+ }));
+ mock.method(getAxios(radarr), 'delete', async () => {
+ throw { response: { status: 404 } };
+ });
+
+ await assert.doesNotReject(() => radarr.removeMovie(550));
+ });
+
+ it('rethrows errors other than 404', async () => {
+ const radarr = buildRadarr();
+ mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
+ id: 7,
+ title: 'Test Movie',
+ }));
+ mock.method(getAxios(radarr), 'delete', async () => {
+ throw { response: { status: 500 } };
+ });
+
+ await assert.rejects(() => radarr.removeMovie(550));
+ });
+
+ it('rethrows a 404 from the lookup instead of treating it as removed', async () => {
+ const radarr = buildRadarr();
+ mock.method(getAxios(radarr), 'get', async () => {
+ throw { response: { status: 404 } };
+ });
+ const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
+
+ await assert.rejects(
+ () => radarr.removeMovie(550),
+ (e: unknown) =>
+ (e as { response?: { status?: number } }).response?.status === 404
+ );
+ assert.strictEqual(del.mock.callCount(), 0);
+ });
+});
+
+describe('RadarrAPI getMovieByTmdbId', () => {
+ afterEach(() => mock.restoreAll());
+
+ it('rethrows a 401 from the lookup with the status intact', async () => {
+ const radarr = buildRadarr();
+ mock.method(getAxios(radarr), 'get', async () => {
+ throw { response: { status: 401 } };
+ });
+
+ await assert.rejects(
+ () => radarr.getMovieByTmdbId(550),
+ (e: unknown) =>
+ (e as { response?: { status?: number } }).response?.status === 401
+ );
+ });
+
+ it('throws "Movie not found" when the lookup returns no results', async () => {
+ const radarr = buildRadarr();
+ mock.method(getAxios(radarr), 'get', async () => ({ data: [] }));
+
+ await assert.rejects(() => radarr.getMovieByTmdbId(550), {
+ message: 'Movie not found',
+ });
+ });
+});
diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts
index 1d35778c0f..e3b231cb03 100644
--- a/server/api/servarr/radarr.ts
+++ b/server/api/servarr/radarr.ts
@@ -1,4 +1,5 @@
import logger from '@server/logger';
+import type { AxiosResponse } from 'axios';
import ServarrBase from './base';
export interface RadarrMovieOptions {
@@ -80,6 +81,23 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}
};
+ public getLibraryMoviesByTmdbId = async (
+ tmdbId: number
+ ): Promise => {
+ try {
+ const response = await this.axios.get('/movie', {
+ params: { tmdbId },
+ });
+
+ return response.data;
+ } catch (e) {
+ throw new Error(
+ `[Radarr] Failed to retrieve movies by TMDB ID: ${e.message}`,
+ { cause: e }
+ );
+ }
+ };
+
public getMovie = async ({ id }: { id: number }): Promise => {
try {
const response = await this.axios.get(`/movie/${id}`);
@@ -93,26 +111,27 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
};
public async getMovieByTmdbId(id: number): Promise {
+ let response: AxiosResponse;
try {
- const response = await this.axios.get('/movie/lookup', {
+ response = await this.axios.get('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
});
-
- if (!response.data[0]) {
- throw new Error('Movie not found');
- }
-
- return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDB ID', {
label: 'Radarr API',
errorMessage: e.message,
tmdbId: id,
});
- throw new Error('Movie not found', { cause: e });
+ throw e;
}
+
+ if (!response.data[0]) {
+ throw new Error('Movie not found');
+ }
+
+ return response.data[0];
}
public addMovie = async (
@@ -267,9 +286,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
);
}
}
- public removeMovie = async (movieId: number): Promise => {
+ public removeMovie = async (tmdbId: number): Promise => {
+ const { id, title } = await this.getMovieByTmdbId(tmdbId);
+
+ if (!id) {
+ logger.info(`[Radarr] Movie not in library, nothing to remove`, {
+ tmdbId,
+ });
+ return;
+ }
+
try {
- const { id, title } = await this.getMovieByTmdbId(movieId);
await this.axios.delete(`/movie/${id}`, {
params: {
deleteFiles: true,
@@ -278,9 +305,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
});
logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) {
- throw new Error(`[Radarr] Failed to remove movie: ${e.message}`, {
- cause: e,
- });
+ if (e?.response?.status === 404) {
+ logger.info(`[Radarr] Movie already removed from Radarr`, {
+ tmdbId,
+ });
+ return;
+ }
+ throw e;
}
};
diff --git a/server/api/servarr/readarr.ts b/server/api/servarr/readarr.ts
new file mode 100644
index 0000000000..8a1fdc6f63
--- /dev/null
+++ b/server/api/servarr/readarr.ts
@@ -0,0 +1,393 @@
+import logger from '@server/logger';
+import ServarrBase from './base';
+
+export interface MetadataProfile {
+ id: number;
+ name: string;
+ minPopularity: number;
+ skipMissingDate: boolean;
+ skipMissingIsbn: boolean;
+ skipPartsAndSets: boolean;
+ skipSeriesSecondary: boolean;
+ allowedLanguages: string;
+ minPages: number;
+ ignored: string[];
+}
+
+export interface ReadarrBookOptions {
+ title: string;
+ qualityProfileId: number;
+ metadataProfileId: number;
+ tags: number[];
+ profileId: number;
+ rootFolderPath: string;
+ hcId: number;
+ authorHcId: number;
+ monitored?: boolean;
+ searchNow?: boolean;
+}
+
+export interface ReadarrBook {
+ id: number;
+ title: string;
+ authorTitle: string;
+ seriesTitle: string;
+ disambiguation: string;
+ authorId: number;
+ foreignBookId: string;
+ foreignEditionId: string;
+ titleSlug: string;
+ monitored: boolean;
+ anyEditionOk: boolean;
+ author?: object;
+ edition?: object;
+ ratings: {
+ votes: number;
+ value: number;
+ popularity: number;
+ };
+ releaseDate: string;
+ pageCount: number;
+ genres: string[];
+ images: {
+ url: string;
+ coverType: string;
+ extension: string;
+ }[];
+ links: {
+ url: string;
+ name: string;
+ }[];
+ added: string;
+ remoteCover: string;
+ grabbed: boolean;
+ statistics: {
+ bookFileCount: number;
+ bookCount: number;
+ totalBookCount: number;
+ sizeOnDisk: number;
+ percentOfBooks: number;
+ };
+}
+
+interface ReadarrEdition {
+ bookId: number;
+ foreignEditionId: string;
+ titleSlug: string;
+ asin: string;
+ title: string;
+ language: string;
+ overview: string;
+ format: string;
+ isEbook: boolean;
+ disambiguation: string;
+ publisher: string;
+ pageCount: number;
+ releaseDate: string;
+ images: {
+ url: string;
+ coverType: string;
+ extension: string;
+ }[];
+ links: {
+ url: string;
+ name: string;
+ }[];
+ ratings: {
+ votes: number;
+ value: number;
+ popularity: number;
+ };
+ monitored: boolean;
+ manualAdd: boolean;
+ grabbed: boolean;
+ id: number;
+ isbn13?: string;
+}
+
+class ReadarrAPI extends ServarrBase<{ bookId: number }> {
+ constructor({ url, apiKey }: { url: string; apiKey: string }) {
+ super({ url, apiKey, cacheName: 'readarr', apiName: 'Readarr' });
+ }
+
+ public getMetadataProfiles = async (): Promise => {
+ try {
+ const data = await this.getRolling(
+ `/metadataprofile`,
+ undefined,
+ 3600
+ );
+
+ return data;
+ } catch (e) {
+ throw new Error(
+ `[Readarr] Failed to retrieve metadata profiles: ${e.message}`,
+ { cause: e }
+ );
+ }
+ };
+
+ public getBooks = async (): Promise => {
+ try {
+ const data = await this.get('/book');
+
+ return data;
+ } catch (e) {
+ throw new Error(`[Readarr] Failed to retrieve books: ${e.message}`, {
+ cause: e,
+ });
+ }
+ };
+
+ public getBook = async (id: number): Promise => {
+ try {
+ const data = await this.get(`/book/${id}`);
+
+ return data;
+ } catch (e) {
+ throw new Error(`[Readarr] Failed to retrieve book: ${e.message}`, {
+ cause: e,
+ });
+ }
+ };
+
+ public async getBookByHcId(id: number): Promise {
+ try {
+ const newBookId = id;
+ const response = await this.axios.get('/book/lookup', {
+ params: { term: `work:${newBookId}` },
+ });
+ const data = response.data;
+
+ if (!data[0]) {
+ throw new Error('Book not found');
+ }
+
+ return data[0];
+ } catch (e) {
+ logger.error('Error retrieving book by ID', {
+ label: 'Readarr API',
+ errorMessage: e.message,
+ hcId: id,
+ });
+ throw new Error('Book not found', { cause: e });
+ }
+ }
+
+ public getEditions = async (id: number): Promise => {
+ try {
+ const response = await this.get(`/edition`, {
+ params: { bookId: id },
+ });
+
+ return response;
+ } catch (e) {
+ throw new Error(`[Readarr] Failed to retrieve book: ${e.message}`, {
+ cause: e,
+ });
+ }
+ };
+
+ public addBook = async (
+ options: ReadarrBookOptions
+ ): Promise => {
+ try {
+ const newAuthorId = options.authorHcId.toString();
+ const book = await this.getBookByHcId(options.hcId);
+
+ if (book.grabbed) {
+ logger.info(
+ 'Book already exists and is available. Skipping add and returning success',
+ {
+ label: 'Readarr',
+ book: book,
+ }
+ );
+ return book;
+ }
+
+ // book exists in Readarr but is neither downloaded nor monitored
+ if (book.id && !book.monitored) {
+ const bookData = await this.getBook(book.id);
+ const editionData = await this.getEditions(book.id);
+ const response = await this.axios.put(`/book`, {
+ ...bookData,
+ monitored: true,
+ author: {
+ ...bookData.author,
+ qualityProfileId: options.qualityProfileId,
+ metadataProfileId: options.metadataProfileId,
+ tags: options.tags,
+ rootFolderPath: options.rootFolderPath,
+ },
+ editions: editionData.map((edition) => ({
+ ...edition,
+ monitored: edition.foreignEditionId === book.foreignEditionId,
+ })),
+ addOptions: {
+ searchForNewBook: options.searchNow,
+ },
+ });
+ const data = response.data;
+
+ if (data.monitored) {
+ logger.info(
+ 'Found existing book in Readarr and set it to monitored.',
+ {
+ label: 'Readarr',
+ bookId: data.id,
+ bookTitle: data.title,
+ }
+ );
+ logger.debug('Readarr update details', {
+ label: 'Readarr',
+ book: data,
+ });
+
+ if (options.searchNow) {
+ this.searchBook(data.id);
+ }
+
+ return data;
+ } else {
+ logger.error('Failed to update existing book in Readarr.', {
+ label: 'Readarr',
+ options,
+ });
+ throw new Error('Failed to update existing book in Readarr');
+ }
+ }
+
+ if (book.id) {
+ logger.info(
+ 'Book is already monitored in Readarr. Skipping add and returning success',
+ { label: 'Readarr' }
+ );
+ if (options.searchNow) {
+ this.searchBook(book.id);
+ }
+ return book;
+ }
+
+ const data = await this.post(`/book`, {
+ authorId: 0,
+ foreignBookId: book.foreignBookId,
+ monitored: true,
+ anyEditionOk: false,
+ author: {
+ authorMetadataId: 0,
+ foreignAuthorId: newAuthorId,
+ qualityProfileId: options.qualityProfileId,
+ metadataProfileId: options.metadataProfileId,
+ monitored: true,
+ monitorNewItems: 'none',
+ tags: options.tags,
+ addOptions: {
+ searchForMissingBooks: false,
+ booksToMonitor: [book.foreignBookId],
+ },
+ rootFolderPath: options.rootFolderPath,
+ },
+ editions: [
+ {
+ bookId: 0,
+ foreignEditionId: book.foreignEditionId,
+ monitored: true,
+ manualAdd: false,
+ grabbed: false,
+ },
+ ],
+ grabbed: false,
+ addOptions: {
+ searchForNewBook: options.searchNow,
+ },
+ });
+
+ if (data.id) {
+ logger.info('Readarr accepted request', { label: 'Readarr' });
+ logger.debug('Readarr add details', {
+ label: 'Readarr',
+ book: data,
+ });
+
+ if (options.searchNow) {
+ this.searchBook(data.id);
+ }
+ } else {
+ logger.error('Failed to add book to Readarr', {
+ label: 'Readarr',
+ options,
+ });
+ throw new Error('Failed to add book to Readarr');
+ }
+ return data;
+ } catch (e) {
+ logger.error(
+ 'Failed to add book to Readarr. This might happen if the book already exists, in which case you can safely ignore this error.',
+ {
+ label: 'Readarr',
+ errorMessage: e.message,
+ options,
+ response: e?.response?.data,
+ }
+ );
+ throw new Error('Failed to add book to Readarr', { cause: e });
+ }
+ };
+
+ public async searchBook(bookId: number): Promise {
+ logger.info('Executing book search command', {
+ label: 'Readarr API',
+ bookId: bookId,
+ });
+
+ try {
+ await this.runCommand('BookSearch', { bookIds: [bookId] });
+ } catch (e) {
+ logger.error(
+ 'Something went wrong while executing Readarr book search.',
+ {
+ label: 'Readarr API',
+ errorMessage: e.message,
+ bookId: bookId,
+ }
+ );
+ }
+ }
+
+ public removeBook = async (bookId: number): Promise => {
+ try {
+ const { id, title } = await this.getBookByHcId(bookId);
+ await this.axios.delete(`/book/${id}`, {
+ params: {
+ deleteFiles: true,
+ addImportExclusion: false,
+ },
+ });
+ logger.info(`[Readarr] Removed book ${title}`);
+ } catch (e) {
+ throw new Error(`[Readarr] Failed to remove book: ${e.message}`, {
+ cause: e,
+ });
+ }
+ };
+
+ public clearCache = ({
+ tmdbId: hcId,
+ externalId,
+ }: {
+ tmdbId?: number | null;
+ externalId?: number | null;
+ }) => {
+ if (hcId) {
+ this.removeCache('/book/lookup', {
+ term: `work:${hcId}`,
+ });
+ }
+ if (externalId) {
+ this.removeCache(`/book/${externalId}`);
+ }
+ };
+}
+
+export default ReadarrAPI;
diff --git a/server/api/servarr/sonarr.test.ts b/server/api/servarr/sonarr.test.ts
new file mode 100644
index 0000000000..015b57e900
--- /dev/null
+++ b/server/api/servarr/sonarr.test.ts
@@ -0,0 +1,119 @@
+import assert from 'node:assert/strict';
+import { afterEach, describe, it, mock } from 'node:test';
+
+import type { AxiosInstance } from 'axios';
+
+import SonarrAPI from '@server/api/servarr/sonarr';
+
+function buildSonarr(): SonarrAPI {
+ return new SonarrAPI({ url: 'http://localhost:8989/api/v3', apiKey: 'test' });
+}
+
+function getAxios(sonarr: SonarrAPI): AxiosInstance {
+ return (sonarr as unknown as { axios: AxiosInstance }).axios;
+}
+
+describe('SonarrAPI removeSeries', () => {
+ afterEach(() => mock.restoreAll());
+
+ it('removes the series when it exists in the library', async () => {
+ const sonarr = buildSonarr();
+ mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
+ id: 9,
+ title: 'Test Series',
+ }));
+ const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
+
+ await sonarr.removeSeries(1234);
+
+ assert.strictEqual(del.mock.callCount(), 1);
+ assert.strictEqual(del.mock.calls[0].arguments[0], '/series/9');
+ });
+
+ it('does nothing when the series is not in the library', async () => {
+ const sonarr = buildSonarr();
+ mock.method(getAxios(sonarr), 'get', async () => ({
+ data: [{ id: 0, title: 'Breaking Bad' }],
+ }));
+ const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
+
+ await assert.doesNotReject(() => sonarr.removeSeries(1234));
+ assert.strictEqual(del.mock.callCount(), 0);
+ });
+
+ it('rejects when the tvdbId is unknown to the lookup', async () => {
+ const sonarr = buildSonarr();
+ mock.method(getAxios(sonarr), 'get', async () => ({ data: [] }));
+ const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
+
+ await assert.rejects(() => sonarr.removeSeries(1234), /Series not found/);
+ assert.strictEqual(del.mock.callCount(), 0);
+ });
+
+ it('ignores a 404 when the series was already removed in Sonarr', async () => {
+ const sonarr = buildSonarr();
+ mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
+ id: 9,
+ title: 'Test Series',
+ }));
+ mock.method(getAxios(sonarr), 'delete', async () => {
+ throw { response: { status: 404 } };
+ });
+
+ await assert.doesNotReject(() => sonarr.removeSeries(1234));
+ });
+
+ it('rethrows errors other than 404', async () => {
+ const sonarr = buildSonarr();
+ mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
+ id: 9,
+ title: 'Test Series',
+ }));
+ mock.method(getAxios(sonarr), 'delete', async () => {
+ throw { response: { status: 500 } };
+ });
+
+ await assert.rejects(() => sonarr.removeSeries(1234));
+ });
+
+ it('rethrows a 404 from the lookup instead of treating it as removed', async () => {
+ const sonarr = buildSonarr();
+ mock.method(getAxios(sonarr), 'get', async () => {
+ throw { response: { status: 404 } };
+ });
+ const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
+
+ await assert.rejects(
+ () => sonarr.removeSeries(1234),
+ (e: unknown) =>
+ (e as { response?: { status?: number } }).response?.status === 404
+ );
+ assert.strictEqual(del.mock.callCount(), 0);
+ });
+});
+
+describe('SonarrAPI getSeriesByTvdbId', () => {
+ afterEach(() => mock.restoreAll());
+
+ it('rethrows a 401 from the lookup with the status intact', async () => {
+ const sonarr = buildSonarr();
+ mock.method(getAxios(sonarr), 'get', async () => {
+ throw { response: { status: 401 } };
+ });
+
+ await assert.rejects(
+ () => sonarr.getSeriesByTvdbId(1234),
+ (e: unknown) =>
+ (e as { response?: { status?: number } }).response?.status === 401
+ );
+ });
+
+ it('throws "Series not found" when the lookup returns no results', async () => {
+ const sonarr = buildSonarr();
+ mock.method(getAxios(sonarr), 'get', async () => ({ data: [] }));
+
+ await assert.rejects(() => sonarr.getSeriesByTvdbId(1234), {
+ message: 'Series not found',
+ });
+ });
+});
diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts
index 33354e1cda..29679d0e51 100644
--- a/server/api/servarr/sonarr.ts
+++ b/server/api/servarr/sonarr.ts
@@ -1,4 +1,5 @@
import logger from '@server/logger';
+import type { AxiosResponse } from 'axios';
import ServarrBase from './base';
export interface SonarrSeason {
@@ -129,6 +130,23 @@ class SonarrAPI extends ServarrBase<{
}
}
+ public async getLibrarySeriesByTvdbId(
+ tvdbId: number
+ ): Promise {
+ try {
+ const response = await this.axios.get('/series', {
+ params: { tvdbId },
+ });
+
+ return response.data;
+ } catch (e) {
+ throw new Error(
+ `[Sonarr] Failed to retrieve series by TVDB ID: ${e.message}`,
+ { cause: e }
+ );
+ }
+ }
+
public async getSeriesById(id: number): Promise {
try {
const response = await this.axios.get(`/series/${id}`);
@@ -166,26 +184,27 @@ class SonarrAPI extends ServarrBase<{
}
public async getSeriesByTvdbId(id: number): Promise {
+ let response: AxiosResponse;
try {
- const response = await this.axios.get('/series/lookup', {
+ response = await this.axios.get('/series/lookup', {
params: {
term: `tvdb:${id}`,
},
});
-
- if (!response.data[0]) {
- throw new Error('Series not found');
- }
-
- return response.data[0];
} catch (e) {
logger.error('Error retrieving series by tvdb ID', {
label: 'Sonarr API',
errorMessage: e.message,
tvdbId: id,
});
- throw new Error('Series not found', { cause: e });
+ throw e;
+ }
+
+ if (!response.data[0]) {
+ throw new Error('Series not found');
}
+
+ return response.data[0];
}
public async addSeries(options: AddSeriesOptions): Promise {
@@ -410,9 +429,17 @@ class SonarrAPI extends ServarrBase<{
return newSeasons;
}
- public removeSeries = async (serieId: number): Promise => {
+ public removeSeries = async (tvdbId: number): Promise => {
+ const { id, title } = await this.getSeriesByTvdbId(tvdbId);
+
+ if (!id) {
+ logger.info(`[Sonarr] Series not in library, nothing to remove`, {
+ tvdbId,
+ });
+ return;
+ }
+
try {
- const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.axios.delete(`/series/${id}`, {
params: {
deleteFiles: true,
@@ -421,9 +448,13 @@ class SonarrAPI extends ServarrBase<{
});
logger.info(`[Sonarr] Removed series ${title}`);
} catch (e) {
- throw new Error(`[Sonarr] Failed to remove series: ${e.message}`, {
- cause: e,
- });
+ if (e?.response?.status === 404) {
+ logger.info(`[Sonarr] Series already removed from Sonarr`, {
+ tvdbId,
+ });
+ return;
+ }
+ throw e;
}
};
diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts
index 977560a575..12da124d91 100644
--- a/server/api/tautulli.ts
+++ b/server/api/tautulli.ts
@@ -1,7 +1,8 @@
import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger';
-import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
+import { proxyRequestInterceptor } from '@server/utils/customProxyAgent';
+import { userAgentRequestInterceptor } from '@server/utils/userAgent';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash';
@@ -124,7 +125,8 @@ class TautulliAPI {
}${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey },
});
- this.axios.interceptors.request.use(requestInterceptorFunction);
+ this.axios.interceptors.request.use(proxyRequestInterceptor);
+ this.axios.interceptors.request.use(userAgentRequestInterceptor);
}
public async getInfo(): Promise {
diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts
index 305d6048d7..4693b3e86e 100644
--- a/server/api/themoviedb/index.ts
+++ b/server/api/themoviedb/index.ts
@@ -1,5 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowProvider } from '@server/api/provider';
+import type { CacheStore } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import { sortBy } from 'lodash';
@@ -23,6 +24,7 @@ import type {
TmdbSearchTvResponse,
TmdbSeasonWithEpisodes,
TmdbTvDetails,
+ TmdbTvScanDetails,
TmdbUpcomingMoviesResponse,
TmdbWatchProviderDetails,
TmdbWatchProviderRegion,
@@ -39,26 +41,38 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
-export const SortOptionsIterable = [
+const CommonSortOptionsIterable = [
'popularity.desc',
'popularity.asc',
+ 'vote_average.desc',
+ 'vote_average.asc',
+ 'vote_count.desc',
+ 'vote_count.asc',
+] as const;
+
+export const MovieSortOptionsIterable = [
+ ...CommonSortOptionsIterable,
'release_date.desc',
'release_date.asc',
'revenue.desc',
'revenue.asc',
'primary_release_date.desc',
'primary_release_date.asc',
- 'original_title.asc',
'original_title.desc',
- 'vote_average.desc',
- 'vote_average.asc',
- 'vote_count.desc',
- 'vote_count.asc',
+ 'original_title.asc',
+] as const;
+
+export const TvSortOptionsIterable = [
+ ...CommonSortOptionsIterable,
'first_air_date.desc',
'first_air_date.asc',
+ 'original_name.desc',
+ 'original_name.asc',
] as const;
-export type SortOptions = (typeof SortOptionsIterable)[number];
+export type MovieSortOptions = (typeof MovieSortOptionsIterable)[number];
+export type TvSortOptions = (typeof TvSortOptionsIterable)[number];
+export type SortOptions = MovieSortOptions | TvSortOptions;
export interface TmdbCertificationResponse {
certifications: {
@@ -88,7 +102,7 @@ interface DiscoverMovieOptions {
studio?: string;
keywords?: string;
excludeKeywords?: string;
- sortBy?: SortOptions;
+ sortBy?: MovieSortOptions;
watchRegion?: string;
watchProviders?: string;
certification?: string;
@@ -114,7 +128,7 @@ interface DiscoverTvOptions {
network?: number;
keywords?: string;
excludeKeywords?: string;
- sortBy?: SortOptions;
+ sortBy?: TvSortOptions;
watchRegion?: string;
watchProviders?: string;
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
@@ -124,7 +138,43 @@ interface DiscoverTvOptions {
certificationCountry?: string;
}
+const TV_DETAILS_TTL = 43200;
+
+// Scan lookups read an entry once and never again.
+const SCAN_TTL = 900;
+
+const TV_DETAILS_APPEND_TO_RESPONSE =
+ 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers';
+
+const TV_SCAN_APPEND_TO_RESPONSE = 'keywords,external_ids';
+
+type ExternalIdLookup =
+ | {
+ externalId: string;
+ type: 'imdb';
+ language?: string;
+ }
+ | {
+ externalId: number;
+ type: 'tvdb';
+ language?: string;
+ };
+
+// Nothing anywhere reads aggregate_credits.crew or credits.cast.
+const stripUnreadTvCredits = (data: T): T => {
+ const show = data as {
+ aggregate_credits?: { crew?: unknown };
+ credits?: { cast?: unknown };
+ };
+
+ delete show.aggregate_credits?.crew;
+ delete show.credits?.cast;
+
+ return data;
+};
+
class TheMovieDb extends ExternalAPI implements TvShowProvider {
+ private scanCache = cacheManager.getCache('tmdbscan').data;
private locale: string;
private discoverRegion?: string;
private originalLanguage?: string;
@@ -341,6 +391,37 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
};
+ // The append list is part of the cache key, so full and narrow entries differ.
+ private getTvShowDetails = async ({
+ tvId,
+ language,
+ appendToResponse,
+ includeVideoLanguage,
+ ttl,
+ cache,
+ }: {
+ tvId: number;
+ language?: string;
+ appendToResponse: string;
+ includeVideoLanguage?: string;
+ ttl: number;
+ cache?: CacheStore;
+ }): Promise =>
+ this.get(
+ `/tv/${tvId}`,
+ {
+ params: {
+ language,
+ append_to_response: appendToResponse,
+ ...(includeVideoLanguage
+ ? { include_video_language: includeVideoLanguage }
+ : {}),
+ },
+ },
+ ttl,
+ { cache, transform: stripUnreadTvCredits }
+ );
+
public getTvShow = async ({
tvId,
language = this.locale,
@@ -349,35 +430,26 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
language?: string;
}): Promise => {
try {
- const data = await this.get(
- `/tv/${tvId}`,
- {
- params: {
- language,
- append_to_response:
- 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
- include_video_language: language,
- },
- },
- 43200
- );
+ const data = await this.getTvShowDetails({
+ tvId,
+ language,
+ appendToResponse: TV_DETAILS_APPEND_TO_RESPONSE,
+ includeVideoLanguage: language,
+ ttl: TV_DETAILS_TTL,
+ });
if (
(!language || !language.startsWith('en')) &&
!data.videos?.results?.some((video) => video.type === 'Trailer')
) {
try {
- const fallback = await this.get(
- `/tv/${tvId}`,
- {
- params: {
- language,
- append_to_response: 'videos',
- include_video_language: 'en',
- },
- },
- 43200
- );
+ const fallback = await this.getTvShowDetails({
+ tvId,
+ language,
+ appendToResponse: 'videos',
+ includeVideoLanguage: 'en',
+ ttl: TV_DETAILS_TTL,
+ });
const localizedVideos = data.videos?.results ?? [];
const localizedVideoKeys = new Set(
@@ -408,6 +480,29 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
};
+ public getTvShowForScan = async ({
+ tvId,
+ language = this.locale,
+ }: {
+ tvId: number;
+ language?: string;
+ }): Promise => {
+ try {
+ return await this.getTvShowDetails({
+ tvId,
+ language,
+ appendToResponse: TV_SCAN_APPEND_TO_RESPONSE,
+ ttl: SCAN_TTL,
+ cache: this.scanCache,
+ });
+ } catch (e) {
+ throw new Error(
+ `[TMDB] Failed to fetch TV show scan details: ${e.message}`,
+ { cause: e }
+ );
+ }
+ };
+
public getTvSeason = async ({
tvId,
seasonNumber,
@@ -871,33 +966,36 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
};
- public async getByExternalId({
- externalId,
- type,
- language = this.locale,
- }:
- | {
- externalId: string;
- type: 'imdb';
- language?: string;
- }
- | {
- externalId: number;
- type: 'tvdb';
- language?: string;
- }): Promise {
+ public async getByExternalId(
+ lookup: ExternalIdLookup
+ ): Promise {
+ return this.findByExternalId(lookup);
+ }
+
+ public async getByExternalIdForScan(
+ lookup: ExternalIdLookup
+ ): Promise {
+ return this.findByExternalId(lookup, this.scanCache, SCAN_TTL);
+ }
+
+ // Omitting ttl here silently falls back to DEFAULT_TTL, regardless of tier.
+ private async findByExternalId(
+ { externalId, type, language = this.locale }: ExternalIdLookup,
+ cache?: CacheStore,
+ ttl?: number
+ ): Promise {
try {
- const data = await this.get(
+ return await this.get(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
- }
+ },
+ ttl,
+ { cache }
);
-
- return data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`, {
cause: e,
@@ -905,35 +1003,31 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
}
- public async getMediaByImdbId({
+ // A /find hit can be stale, so confirm the matched id still exists.
+ public async resolveImdbIdForScan({
imdbId,
- language = this.locale,
}: {
imdbId: string;
- language?: string;
- }): Promise {
+ }): Promise {
try {
- const extResponse = await this.getByExternalId({
- externalId: imdbId,
- type: 'imdb',
- });
+ const extResponse = await this.findByExternalId(
+ { externalId: imdbId, type: 'imdb' },
+ this.scanCache,
+ SCAN_TTL
+ );
if (extResponse.movie_results[0]) {
- const movie = await this.getMovie({
- movieId: extResponse.movie_results[0].id,
- language,
- });
-
- return movie;
+ return await this.assertMovieExistsForScan(
+ extResponse.movie_results[0].id
+ );
}
if (extResponse.tv_results[0]) {
- const tvshow = await this.getTvShow({
+ const tvShow = await this.getTvShowForScan({
tvId: extResponse.tv_results[0].id,
- language,
});
- return tvshow;
+ return tvShow.id;
}
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
@@ -945,6 +1039,35 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
}
+ private async assertMovieExistsForScan(movieId: number): Promise {
+ const data = await this.get<{ id: number }>(
+ `/movie/${movieId}`,
+ {},
+ SCAN_TTL,
+ { cache: this.scanCache }
+ );
+
+ return data.id;
+ }
+
+ private async resolveTvdbId(
+ tvdbId: number,
+ cache?: CacheStore,
+ ttl?: number
+ ): Promise {
+ const extResponse = await this.findByExternalId(
+ { externalId: tvdbId, type: 'tvdb' },
+ cache,
+ ttl
+ );
+
+ if (!extResponse.tv_results[0]) {
+ throw new Error(`No show returned from API for ID ${tvdbId}`);
+ }
+
+ return extResponse.tv_results[0].id;
+ }
+
public async getShowByTvdbId({
tvdbId,
language = this.locale,
@@ -953,21 +1076,30 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
language?: string;
}): Promise {
try {
- const extResponse = await this.getByExternalId({
- externalId: tvdbId,
- type: 'tvdb',
+ return await this.getTvShow({
+ tvId: await this.resolveTvdbId(tvdbId),
+ language,
});
+ } catch (e) {
+ throw new Error(
+ `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`,
+ { cause: e }
+ );
+ }
+ }
- if (extResponse.tv_results[0]) {
- const tvshow = await this.getTvShow({
- tvId: extResponse.tv_results[0].id,
- language,
- });
-
- return tvshow;
- }
-
- throw new Error(`No show returned from API for ID ${tvdbId}`);
+ public async getShowByTvdbIdForScan({
+ tvdbId,
+ language = this.locale,
+ }: {
+ tvdbId: number;
+ language?: string;
+ }): Promise {
+ try {
+ return await this.getTvShowForScan({
+ tvId: await this.resolveTvdbId(tvdbId, this.scanCache, SCAN_TTL),
+ language,
+ });
} catch (e) {
throw new Error(
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`,
diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts
index a4c49e9ab0..cd68ccc70b 100644
--- a/server/api/themoviedb/interfaces.ts
+++ b/server/api/themoviedb/interfaces.ts
@@ -304,6 +304,16 @@ export interface TmdbTvDetails {
};
}
+export interface TmdbTvScanDetails {
+ id: number;
+ name: string;
+ seasons: TmdbTvSeasonResult[];
+ external_ids: TmdbExternalIds;
+ keywords: {
+ results: TmdbKeyword[];
+ };
+}
+
export interface TmdbVideoResult {
results: TmdbVideo[];
}
diff --git a/server/constants/discord.ts b/server/constants/discord.ts
new file mode 100644
index 0000000000..c6b7fc1ca1
--- /dev/null
+++ b/server/constants/discord.ts
@@ -0,0 +1,27 @@
+export const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/;
+
+export enum EmbedColors {
+ DEFAULT = 0,
+ AQUA = 1752220,
+ GREEN = 3066993,
+ BLUE = 3447003,
+ PURPLE = 10181046,
+ GOLD = 15844367,
+ ORANGE = 15105570,
+ RED = 15158332,
+ GREY = 9807270,
+ DARKER_GREY = 8359053,
+ NAVY = 3426654,
+ DARK_AQUA = 1146986,
+ DARK_GREEN = 2067276,
+ DARK_BLUE = 2123412,
+ DARK_PURPLE = 7419530,
+ DARK_GOLD = 12745742,
+ DARK_ORANGE = 11027200,
+ DARK_RED = 10038562,
+ DARK_GREY = 9936031,
+ LIGHT_GREY = 12370112,
+ DARK_NAVY = 2899536,
+ LUMINOUS_VIVID_PINK = 16580705,
+ DARK_VIVID_PINK = 12320855,
+}
diff --git a/server/constants/discover.ts b/server/constants/discover.ts
index fda0682243..2d431ef5ef 100644
--- a/server/constants/discover.ts
+++ b/server/constants/discover.ts
@@ -22,6 +22,7 @@ export enum DiscoverSliderType {
TMDB_NETWORK,
TMDB_MOVIE_STREAMING_SERVICES,
TMDB_TV_STREAMING_SERVICES,
+ POPULAR_BOOKS,
}
export const defaultSliders: Partial[] = [
@@ -97,4 +98,10 @@ export const defaultSliders: Partial[] = [
isBuiltIn: true,
order: 11,
},
+ {
+ type: DiscoverSliderType.POPULAR_BOOKS,
+ enabled: false,
+ isBuiltIn: true,
+ order: 12,
+ },
];
diff --git a/server/constants/error.ts b/server/constants/error.ts
index daa02f1a1c..54640b206b 100644
--- a/server/constants/error.ts
+++ b/server/constants/error.ts
@@ -5,6 +5,7 @@ export enum ApiErrorCode {
InvalidEmail = 'INVALID_EMAIL',
NotAdmin = 'NOT_ADMIN',
NoAdminUser = 'NO_ADMIN_USER',
+ ConnectionError = 'CONNECTION_ERROR',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unauthorized = 'UNAUTHORIZED',
diff --git a/server/constants/media.ts b/server/constants/media.ts
index 170109fb5c..da3c430d51 100644
--- a/server/constants/media.ts
+++ b/server/constants/media.ts
@@ -9,8 +9,18 @@ export enum MediaRequestStatus {
export enum MediaType {
MOVIE = 'movie',
TV = 'tv',
+ BOOK = 'book',
}
+export const MEDIA_IDENTIFIER_TYPES = {
+ [MediaType.MOVIE]: 'tmdb',
+ [MediaType.TV]: 'tmdb',
+ [MediaType.BOOK]: 'hardcover',
+} as const;
+
+export type IdentifierType =
+ (typeof MEDIA_IDENTIFIER_TYPES)[keyof typeof MEDIA_IDENTIFIER_TYPES];
+
export enum MediaStatus {
UNKNOWN = 1,
PENDING,
diff --git a/server/datasource.ts b/server/datasource.ts
index 699c0bfeb7..79e84d9737 100644
--- a/server/datasource.ts
+++ b/server/datasource.ts
@@ -1,3 +1,22 @@
+import { Blocklist } from '@server/entity/Blocklist';
+import DiscoverSlider from '@server/entity/DiscoverSlider';
+import Issue from '@server/entity/Issue';
+import IssueComment from '@server/entity/IssueComment';
+import Media from '@server/entity/Media';
+import { MediaRequest } from '@server/entity/MediaRequest';
+import OverrideRule from '@server/entity/OverrideRule';
+import Season from '@server/entity/Season';
+import SeasonRequest from '@server/entity/SeasonRequest';
+import { Session } from '@server/entity/Session';
+import { User } from '@server/entity/User';
+import { UserPushSubscription } from '@server/entity/UserPushSubscription';
+import { UserSettings } from '@server/entity/UserSettings';
+import { Watchlist } from '@server/entity/Watchlist';
+import { IssueCommentSubscriber } from '@server/subscriber/IssueCommentSubscriber';
+import { IssueSubscriber } from '@server/subscriber/IssueSubscriber';
+import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber';
+import { MediaSubscriber } from '@server/subscriber/MediaSubscriber';
+import { isPgsql } from '@server/utils/dbType';
import fs from 'fs';
import type { TlsOptions } from 'tls';
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
@@ -5,6 +24,30 @@ import { DataSource } from 'typeorm';
const DB_SSL_PREFIX = 'DB_SSL_';
+const entities = [
+ Blocklist,
+ DiscoverSlider,
+ Issue,
+ IssueComment,
+ Media,
+ MediaRequest,
+ OverrideRule,
+ Season,
+ SeasonRequest,
+ Session,
+ User,
+ UserPushSubscription,
+ UserSettings,
+ Watchlist,
+];
+
+const subscribers = [
+ IssueCommentSubscriber,
+ IssueSubscriber,
+ MediaRequestSubscriber,
+ MediaSubscriber,
+];
+
function boolFromEnv(envVar: string, defaultVal = false) {
if (process.env[envVar]) {
return process.env[envVar]?.toLowerCase() === 'true';
@@ -53,9 +96,9 @@ const testConfig: DataSourceOptions = {
synchronize: true,
dropSchema: true,
logging: boolFromEnv('DB_LOG_QUERIES'),
- entities: ['server/entity/**/*.ts'],
+ entities,
migrations: ['server/migration/sqlite/**/*.ts'],
- subscribers: ['server/subscriber/**/*.ts'],
+ subscribers,
};
const devConfig: DataSourceOptions = {
@@ -67,9 +110,9 @@ const devConfig: DataSourceOptions = {
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
enableWAL: true,
- entities: ['server/entity/**/*.ts'],
+ entities,
migrations: ['server/migration/sqlite/**/*.ts'],
- subscribers: ['server/subscriber/**/*.ts'],
+ subscribers,
};
const prodConfig: DataSourceOptions = {
@@ -81,9 +124,9 @@ const prodConfig: DataSourceOptions = {
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
enableWAL: true,
- entities: ['dist/entity/**/*.js'],
+ entities,
migrations: ['dist/migration/sqlite/**/*.js'],
- subscribers: ['dist/subscriber/**/*.js'],
+ subscribers,
};
const postgresDevConfig: DataSourceOptions = {
@@ -97,12 +140,14 @@ const postgresDevConfig: DataSourceOptions = {
database: process.env.DB_NAME ?? 'seerr',
ssl: buildSslConfig(),
poolSize: intFromEnv('DB_POOL_SIZE'),
+ // Bounds pool acquisition waits so exhaustion surfaces as errors instead of a silent hang
+ connectTimeoutMS: intFromEnv('DB_CONNECT_TIMEOUT_MS', 30000),
synchronize: false,
- migrationsRun: true,
+ migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
- entities: ['server/entity/**/*.ts'],
+ entities,
migrations: ['server/migration/postgres/**/*.ts'],
- subscribers: ['server/subscriber/**/*.ts'],
+ subscribers,
};
const postgresProdConfig: DataSourceOptions = {
@@ -116,16 +161,16 @@ const postgresProdConfig: DataSourceOptions = {
database: process.env.DB_NAME ?? 'seerr',
ssl: buildSslConfig(),
poolSize: intFromEnv('DB_POOL_SIZE'),
+ // Bounds pool acquisition waits so exhaustion surfaces as errors instead of a silent hang
+ connectTimeoutMS: intFromEnv('DB_CONNECT_TIMEOUT_MS', 30000),
synchronize: false,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
- entities: ['dist/entity/**/*.js'],
+ entities,
migrations: ['dist/migration/postgres/**/*.js'],
- subscribers: ['dist/subscriber/**/*.js'],
+ subscribers,
};
-export const isPgsql = process.env.DB_TYPE === 'postgres';
-
function getDataSource(): DataSourceOptions {
if (process.env.NODE_ENV === 'test') {
return testConfig;
diff --git a/server/entity/Media.ts b/server/entity/Media.ts
index a63003df66..cd22faba87 100644
--- a/server/entity/Media.ts
+++ b/server/entity/Media.ts
@@ -1,6 +1,11 @@
import RadarrAPI from '@server/api/servarr/radarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
import SonarrAPI from '@server/api/servarr/sonarr';
-import { MediaStatus, MediaType } from '@server/constants/media';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { Blocklist } from '@server/entity/Blocklist';
@@ -8,6 +13,7 @@ import type { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
+import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
@@ -31,7 +37,8 @@ import Season from './Season';
class Media {
public static async getRelatedMedia(
user: User | undefined,
- items: { tmdbId: number; mediaType: string }[]
+ items: { tmdbId: number; mediaType: string }[],
+ { includeActiveRequest = false }: { includeActiveRequest?: boolean } = {}
): Promise {
const mediaRepository = getRepository(Media);
@@ -49,13 +56,41 @@ class Media {
'watchlist',
'media.id= watchlist.media and watchlist.requestedBy = :userId',
{ userId: user?.id }
- ) //,
+ )
+ .leftJoinAndSelect('media.requests', 'requests')
.where(' media.tmdbId in (:...finalIds)', { finalIds })
.getMany();
- return media.filter((m) =>
+ const relatedMedia = media.filter((m) =>
items.some((i) => i.tmdbId === m.tmdbId && i.mediaType === m.mediaType)
);
+
+ if (
+ includeActiveRequest &&
+ getSettings().main.hideRequested &&
+ relatedMedia.length > 0
+ ) {
+ const activeRequestMediaIds = await mediaRepository
+ .createQueryBuilder('media')
+ .select('media.id', 'id')
+ .distinct(true)
+ .innerJoin('media.requests', 'request')
+ .where('media.id IN (:...mediaIds)', {
+ mediaIds: relatedMedia.map((m) => m.id),
+ })
+ .andWhere('request.status IN (:...statuses)', {
+ statuses: [MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED],
+ })
+ .getRawMany<{ id: number }>();
+
+ const activeIds = new Set(activeRequestMediaIds.map((row) => row.id));
+
+ relatedMedia.forEach((m) => {
+ m.hasActiveRequest = activeIds.has(m.id);
+ });
+ }
+
+ return relatedMedia;
} catch (e) {
logger.error(e.message);
return [];
@@ -187,6 +222,7 @@ class Media {
public serviceUrl?: string;
public serviceUrl4k?: string;
+ public hasActiveRequest?: boolean;
public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = [];
@@ -203,17 +239,21 @@ 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;
+ public resetServiceData(is4k?: boolean): void {
+ if (is4k === undefined || !is4k) {
+ this.serviceId = null;
+ this.externalServiceId = null;
+ this.externalServiceSlug = null;
+ this.ratingKey = null;
+ this.jellyfinMediaId = null;
+ }
+ if (is4k === undefined || is4k) {
+ this.serviceId4k = null;
+ this.externalServiceId4k = null;
+ this.externalServiceSlug4k = null;
+ this.ratingKey4k = null;
+ this.jellyfinMediaId4k = null;
+ }
}
@AfterLoad()
@@ -332,6 +372,34 @@ class Media {
}
}
}
+
+ if (this.mediaType === MediaType.BOOK) {
+ if (this.serviceId !== null && this.externalServiceId !== null) {
+ const settings = getSettings();
+ const server = settings.readarr.find(
+ (readarr) => readarr.id === this.serviceId
+ );
+
+ if (server) {
+ this.serviceUrl = server.externalUrl
+ ? `${server.externalUrl}/book/${this.externalServiceId}`
+ : ReadarrAPI.buildUrl(server, `/book/${this.externalServiceId}`);
+ }
+ }
+
+ if (this.serviceId4k !== null && this.externalServiceId4k !== null) {
+ const settings = getSettings();
+ const server = settings.readarr.find(
+ (readarr) => readarr.id === this.serviceId4k
+ );
+
+ if (server) {
+ this.serviceUrl4k = server.externalUrl
+ ? `${server.externalUrl}/book/${this.externalServiceId4k}`
+ : ReadarrAPI.buildUrl(server, `/book/${this.externalServiceId4k}`);
+ }
+ }
+ }
}
@AfterLoad()
@@ -387,6 +455,64 @@ class Media {
);
}
}
+
+ if (this.mediaType === MediaType.BOOK) {
+ if (
+ this.externalServiceId !== undefined &&
+ this.externalServiceId !== null &&
+ this.serviceId !== undefined &&
+ this.serviceId !== null
+ ) {
+ this.downloadStatus = downloadTracker.getBookProgress(
+ this.serviceId,
+ this.externalServiceId
+ );
+ }
+
+ if (
+ this.externalServiceId4k !== undefined &&
+ this.externalServiceId4k !== null &&
+ this.serviceId4k !== undefined &&
+ this.serviceId4k !== null
+ ) {
+ this.downloadStatus4k = downloadTracker.getBookProgress(
+ this.serviceId4k,
+ this.externalServiceId4k
+ );
+ }
+ }
+ }
+
+ public filter(user?: User): Media {
+ const canViewIssues =
+ user?.hasPermission(
+ [
+ Permission.MANAGE_ISSUES,
+ Permission.VIEW_ISSUES,
+ Permission.CREATE_ISSUES,
+ ],
+ { type: 'or' }
+ ) ?? false;
+
+ return {
+ ...this,
+ requests: (this.requests ?? []).map((request) => ({
+ ...request,
+ requestedBy: request.requestedBy?.filter(),
+ modifiedBy: request.modifiedBy?.filter(),
+ })),
+ // the detail pages call issues.filter() without a null check
+ issues: canViewIssues
+ ? (this.issues ?? []).map(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ ({ comments, problemSeason, problemEpisode, ...issue }) => ({
+ ...issue,
+ createdBy: issue.createdBy?.filter(),
+ modifiedBy: issue.modifiedBy?.filter(),
+ })
+ )
+ : [],
+ } as Media;
}
}
diff --git a/server/entity/MediaRequest.test.ts b/server/entity/MediaRequest.test.ts
new file mode 100644
index 0000000000..27a70b7de2
--- /dev/null
+++ b/server/entity/MediaRequest.test.ts
@@ -0,0 +1,205 @@
+import assert from 'node:assert/strict';
+import { beforeEach, describe, it, mock } from 'node:test';
+
+import ExternalAPI from '@server/api/externalapi';
+import { MediaType } from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import Media from '@server/entity/Media';
+import {
+ DuplicateMediaRequestError,
+ MediaRequest,
+ QuotaRestrictedError,
+} from '@server/entity/MediaRequest';
+import SeasonRequest from '@server/entity/SeasonRequest';
+import { User } from '@server/entity/User';
+import { Permission } from '@server/lib/permissions';
+import { setupTestDb } from '@server/test/db';
+
+// get is a prototype method unlike getMovie, and replaces the cache lookup too
+const externalApiGetMock = mock.method(
+ ExternalAPI.prototype as unknown as {
+ get: (endpoint: string) => Promise;
+ },
+ 'get',
+ async (endpoint: string) => {
+ const tmdbId = Number(endpoint.replace(/^\/(movie|tv)\//, ''));
+
+ if (!tmdbId) {
+ throw new Error(`Unstubbed external endpoint: ${endpoint}`);
+ }
+
+ return {
+ id: tmdbId,
+ external_ids: {},
+ seasons: [1, 2, 3].map((season_number) => ({ season_number })),
+ // Skips getMovie's localized fallback call
+ videos: { results: [{ type: 'Trailer', key: 'trailer' }] },
+ };
+ }
+).mock;
+
+mock.method(MediaRequest, 'sendNotification', async () => undefined);
+
+setupTestDb();
+
+beforeEach(() => {
+ externalApiGetMock.resetCalls();
+});
+
+async function seedRequester(movieQuotaLimit: number): Promise {
+ const userRepository = getRepository(User);
+
+ const requester = await userRepository.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+ requester.movieQuotaLimit = movieQuotaLimit;
+
+ return userRepository.save(requester);
+}
+
+async function createRequester(
+ email: string,
+ permissions = Permission.REQUEST
+): Promise {
+ return getRepository(User).save(new User({ email, permissions, avatar: '' }));
+}
+
+function requestMovies(mediaIds: number[], requester: User) {
+ return Promise.allSettled(
+ mediaIds.map((mediaId) =>
+ MediaRequest.request(
+ { mediaId, mediaType: MediaType.MOVIE, is4k: false },
+ requester
+ )
+ )
+ );
+}
+
+function rejections(results: PromiseSettledResult[]) {
+ return results.filter(
+ (result): result is PromiseRejectedResult => result.status === 'rejected'
+ );
+}
+
+describe('MediaRequest.request', () => {
+ it('rejects the second of two concurrent requests at the movie quota', async () => {
+ const requestRepository = getRepository(MediaRequest);
+ const requester = await seedRequester(1);
+
+ const results = await requestMovies([11111, 22222], requester);
+ const rejected = rejections(results);
+
+ assert.strictEqual(rejected.length, 1);
+ assert.ok(rejected[0].reason instanceof QuotaRestrictedError);
+ assert.strictEqual(await requestRepository.count(), 1);
+ assert.strictEqual(externalApiGetMock.callCount(), 1);
+ });
+
+ it('rejects a concurrent duplicate request for the same movie', async () => {
+ const requestRepository = getRepository(MediaRequest);
+ const requester = await seedRequester(5);
+
+ const results = await requestMovies([33333, 33333], requester);
+ const rejected = rejections(results);
+
+ assert.strictEqual(rejected.length, 1);
+ assert.ok(rejected[0].reason instanceof DuplicateMediaRequestError);
+ assert.strictEqual(await requestRepository.count(), 1);
+ assert.strictEqual(externalApiGetMock.callCount(), 2);
+ });
+
+ it('rejects a duplicate request that omits is4k', async () => {
+ const requestRepository = getRepository(MediaRequest);
+ const requester = await seedRequester(5);
+ const body = { mediaId: 66666, mediaType: MediaType.MOVIE };
+
+ await MediaRequest.request(body, requester);
+
+ await assert.rejects(
+ () => MediaRequest.request(body, requester),
+ DuplicateMediaRequestError
+ );
+ assert.strictEqual(await requestRepository.count(), 1);
+ });
+
+ it('rejects a concurrent duplicate request from a different user', async () => {
+ const requestRepository = getRepository(MediaRequest);
+ const requester = await seedRequester(5);
+ const otherRequester = await createRequester('second@seerr.dev');
+
+ const results = await Promise.allSettled(
+ [requester, otherRequester].map((user) =>
+ MediaRequest.request(
+ { mediaId: 44444, mediaType: MediaType.MOVIE, is4k: false },
+ user
+ )
+ )
+ );
+ const rejected = rejections(results);
+
+ assert.strictEqual(rejected.length, 1);
+ assert.ok(rejected[0].reason instanceof DuplicateMediaRequestError);
+ assert.strictEqual(await requestRepository.count(), 1);
+ });
+
+ it('gives an overlapping season to only one of two concurrent users', async () => {
+ const seasonRequestRepository = getRepository(SeasonRequest);
+ const requester = await seedRequester(5);
+ const otherRequester = await createRequester('second@seerr.dev');
+
+ const results = await Promise.allSettled(
+ [
+ [requester, [1, 2]],
+ [otherRequester, [2, 3]],
+ ].map(([user, seasons]) =>
+ MediaRequest.request(
+ {
+ mediaId: 55555,
+ mediaType: MediaType.TV,
+ seasons: seasons as number[],
+ is4k: false,
+ },
+ user as User
+ )
+ )
+ );
+
+ assert.strictEqual(rejections(results).length, 0);
+ assert.strictEqual(
+ await seasonRequestRepository.count({ where: { seasonNumber: 2 } }),
+ 1
+ );
+ assert.strictEqual(await seasonRequestRepository.count(), 3);
+ });
+
+ it('creates one media row for concurrent 4k and non-4k requests', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const requester = await seedRequester(5);
+ const otherRequester = await createRequester(
+ 'second@seerr.dev',
+ Permission.REQUEST_4K
+ );
+
+ const results = await Promise.allSettled(
+ [
+ [requester, false],
+ [otherRequester, true],
+ ].map(([user, is4k]) =>
+ MediaRequest.request(
+ { mediaId: 88888, mediaType: MediaType.MOVIE, is4k: is4k as boolean },
+ user as User
+ )
+ )
+ );
+
+ assert.strictEqual(rejections(results).length, 0);
+ assert.strictEqual(await requestRepository.count(), 2);
+ assert.strictEqual(
+ await mediaRepository.count({
+ where: { tmdbId: 88888, mediaType: MediaType.MOVIE },
+ }),
+ 1
+ );
+ });
+});
diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts
index 0252799220..969a2c8438 100644
--- a/server/entity/MediaRequest.ts
+++ b/server/entity/MediaRequest.ts
@@ -1,19 +1,24 @@
+import Hardcover from '@server/api/hardcover';
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,
MediaStatus,
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 overrideRules from '@server/lib/overrideRules';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
+import requestLock, {
+ mediaKey,
+ mediaLock,
+ userKey,
+} from '@server/utils/requestLock';
+import { isBookDetails } from '@server/utils/typeHelpers';
import { truncate } from 'lodash';
import {
AfterInsert,
@@ -48,8 +53,33 @@ export class MediaRequest {
requestBody: MediaRequestBody,
user: User,
options: MediaRequestOptions = {}
+ ): Promise {
+ // is4k is optional, and an undefined one binds as null in the duplicate query
+ const body = { ...requestBody, is4k: !!requestBody.is4k };
+
+ // Only a caller allowed to set the request user may queue on their lock
+ const lockUserId =
+ body.userId &&
+ user.hasPermission([Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS])
+ ? body.userId
+ : user.id;
+
+ // No is4k in the key: one media row holds both statuses, so a 4k and a
+ // non-4k request for the same title race to create it
+ return requestLock.dispatch(userKey(lockUserId), () =>
+ mediaLock.dispatch(mediaKey(body.mediaType, body.mediaId), () =>
+ MediaRequest.createRequest(body, user, options)
+ )
+ );
+ }
+
+ private static async createRequest(
+ requestBody: MediaRequestBody,
+ user: User,
+ options: MediaRequestOptions
): Promise {
const tmdb = new TheMovieDb();
+ const hardcover = new Hardcover();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
@@ -109,20 +139,65 @@ export class MediaRequest {
requestBody.is4k ? '4K ' : ''
}series requests.`
);
+ } else if (
+ requestBody.mediaType === MediaType.BOOK &&
+ !requestUser.hasPermission(
+ requestBody.is4k
+ ? [Permission.REQUEST_4K, Permission.REQUEST_AUDIO_BOOK]
+ : [Permission.REQUEST, Permission.REQUEST_BOOK],
+ {
+ type: 'or',
+ }
+ )
+ ) {
+ throw new RequestPermissionError(
+ `You do not have permission to make ${
+ requestBody.is4k ? 'audio' : ''
+ }book requests.`
+ );
}
const quotas = await requestUser.getQuota();
- if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
- throw new QuotaRestrictedError('Movie Quota exceeded.');
- } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
- throw new QuotaRestrictedError('Series Quota exceeded.');
+ const canBypassQuota = user.hasPermission(Permission.MANAGE_REQUESTS);
+ const ignoreQuota =
+ requestBody.ignoreQuota === true &&
+ canBypassQuota &&
+ ((requestBody.mediaType === MediaType.MOVIE
+ ? quotas.movie.limit
+ : requestBody.mediaType === MediaType.BOOK
+ ? quotas.book.limit
+ : quotas.tv.limit) ?? 0) > 0;
+
+ if (!ignoreQuota) {
+ if (requestBody.ignoreQuota && !canBypassQuota) {
+ throw new RequestPermissionError(
+ 'You do not have permission to bypass user quota limits.'
+ );
+ } else if (
+ requestBody.mediaType === MediaType.MOVIE &&
+ quotas.movie.restricted
+ ) {
+ throw new QuotaRestrictedError('Movie Quota exceeded.');
+ } else if (
+ requestBody.mediaType === MediaType.TV &&
+ quotas.tv.restricted
+ ) {
+ throw new QuotaRestrictedError('Series Quota exceeded.');
+ } else if (
+ requestBody.mediaType === MediaType.BOOK &&
+ quotas.book.restricted
+ ) {
+ throw new QuotaRestrictedError('Book Quota exceeded.');
+ }
}
const tmdbMedia =
requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId })
- : await tmdb.getTvShow({ tvId: requestBody.mediaId });
+ : requestBody.mediaType === MediaType.BOOK
+ ? await hardcover.getBook(requestBody.mediaId)
+ : await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({
where: {
@@ -135,7 +210,9 @@ export class MediaRequest {
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
- tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
+ ...(!isBookDetails(tmdbMedia) && {
+ tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
+ }),
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
@@ -151,18 +228,26 @@ export class MediaRequest {
throw new BlocklistedMediaError('This media is blocklisted.');
}
- if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
+ if (
+ (media.status === MediaStatus.UNKNOWN ||
+ media.status === MediaStatus.DELETED) &&
+ !requestBody.is4k
+ ) {
media.status = MediaStatus.PENDING;
}
- if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
+ if (
+ (media.status4k === MediaStatus.UNKNOWN ||
+ media.status4k === MediaStatus.DELETED) &&
+ requestBody.is4k
+ ) {
media.status4k = MediaStatus.PENDING;
}
}
const existing = await requestRepository
.createQueryBuilder('request')
- .leftJoin('request.media', 'media')
+ .leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
@@ -172,9 +257,10 @@ export class MediaRequest {
.getMany();
if (existing && existing.length > 0) {
- // If there is an existing movie request that isn't declined, don't allow a new one.
+ // If there is an existing movie/book request that isn't declined, don't allow a new one.
if (
- requestBody.mediaType === MediaType.MOVIE &&
+ (requestBody.mediaType === MediaType.MOVIE ||
+ requestBody.mediaType === MediaType.BOOK) &&
existing[0].status !== MediaRequestStatus.DECLINED &&
existing[0].status !== MediaRequestStatus.COMPLETED
) {
@@ -192,9 +278,13 @@ export class MediaRequest {
// If an existing auto-request for this media exists from the same user,
// don't allow a new one.
+ const statusKey = requestBody.is4k ? 'status4k' : 'status';
if (
existing.find(
- (r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
+ (r) =>
+ r.requestedBy.id === requestUser.id &&
+ r.isAutoRequest &&
+ r.media?.[statusKey] !== MediaStatus.DELETED
)
) {
throw new DuplicateMediaRequestError(
@@ -203,130 +293,50 @@ 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], {
- type: 'or',
- });
-
let rootFolder = requestBody.rootFolder;
let profileId = requestBody.profileId;
let tags = requestBody.tags;
+ const metadataProfileId = requestBody.metadataProfileId;
- 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;
+ // Override rules match TMDB genre, language and keywords, which a book
+ // does not have, so none has ever applied to one
+ if (!isBookDetails(tmdbMedia)) {
+ const ruleResult = await overrideRules({
+ mediaType: requestBody.mediaType,
+ is4k: requestBody.is4k || false,
+ tmdbMedia,
+ requestUser,
+ tags,
});
-
- // 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)),
- ]),
- ];
- }
-
+ const isAdvanced = user.hasPermission(
+ [Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
+ { type: 'or' }
+ );
+ // Advanced users pick these in the modal, so we don't want to override them if they are set
+ const overrideRulesResult = isAdvanced
+ ? {
+ rootFolder: rootFolder ? null : ruleResult.rootFolder,
+ profileId: profileId ? null : ruleResult.profileId,
+ tags: tags ? null : ruleResult.tags,
+ }
+ : ruleResult;
+ if (overrideRulesResult.rootFolder) {
+ rootFolder = overrideRulesResult.rootFolder;
+ }
+ if (overrideRulesResult.profileId) {
+ profileId = overrideRulesResult.profileId;
+ }
+ if (overrideRulesResult.tags) {
+ tags = overrideRulesResult.tags;
+ }
+ if (
+ overrideRulesResult.rootFolder ||
+ overrideRulesResult.profileId ||
+ overrideRulesResult.tags
+ ) {
logger.debug('Override rule applied.', {
- label: 'Media Request',
- overrides: prioritizedRule,
+ label: 'Override Rules',
+ overrides: overrideRulesResult,
});
}
}
@@ -373,6 +383,54 @@ export class MediaRequest {
rootFolder: rootFolder,
tags: tags,
isAutoRequest: options.isAutoRequest ?? false,
+ ignoreQuota,
+ });
+
+ await requestRepository.save(request);
+ return request;
+ } else if (requestBody.mediaType === MediaType.BOOK) {
+ await mediaRepository.save(media);
+
+ const request = new MediaRequest({
+ type: MediaType.BOOK,
+ media,
+ requestedBy: requestUser,
+ // If the user is an admin or has the "auto approve" permission, automatically approve the request
+ status: user.hasPermission(
+ [
+ requestBody.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ requestBody.is4k
+ ? Permission.AUTO_APPROVE_AUDIO_BOOK
+ : Permission.AUTO_APPROVE_BOOK,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? MediaRequestStatus.APPROVED
+ : MediaRequestStatus.PENDING,
+ modifiedBy: user.hasPermission(
+ [
+ requestBody.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ requestBody.is4k
+ ? Permission.AUTO_APPROVE_AUDIO_BOOK
+ : Permission.AUTO_APPROVE_BOOK,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? user
+ : undefined,
+ is4k: requestBody.is4k,
+ serverId: requestBody.serverId,
+ profileId: profileId,
+ metadataProfileId: metadataProfileId,
+ rootFolder: rootFolder,
+ tags: tags,
+ isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
@@ -384,7 +442,10 @@ export class MediaRequest {
let requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
- .filter((season) => season.season_number !== 0)
+ .filter(
+ (season) =>
+ season.season_number !== 0 && season.episode_count > 0
+ )
.map((season) => season.season_number)
: (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
@@ -436,6 +497,7 @@ export class MediaRequest {
if (finalSeasons.length === 0) {
throw new NoSeasonsAvailableError('No seasons available to request');
} else if (
+ !ignoreQuota &&
quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
@@ -504,6 +566,7 @@ export class MediaRequest {
})
),
isAutoRequest: options.isAutoRequest ?? false,
+ ignoreQuota,
});
await requestRepository.save(request);
@@ -570,6 +633,9 @@ export class MediaRequest {
@Column({ nullable: true })
public profileId: number;
+ @Column({ nullable: true })
+ public metadataProfileId: number;
+
@Column({ nullable: true })
public rootFolder: string;
@@ -610,6 +676,9 @@ export class MediaRequest {
@Column({ default: false })
public isAutoRequest: boolean;
+ @Column({ default: false })
+ public ignoreQuota: boolean;
+
constructor(init?: Partial) {
Object.assign(this, init);
}
@@ -727,9 +796,15 @@ export class MediaRequest {
type: Notification
) {
const tmdb = new TheMovieDb();
+ const hardcover = new Hardcover();
try {
- const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
+ const mediaType =
+ entity.type === MediaType.MOVIE
+ ? 'Movie'
+ : entity.type === MediaType.TV
+ ? 'Series'
+ : 'Book';
let event: string | undefined;
let notifyAdmin = true;
let notifySystem = true;
@@ -740,30 +815,62 @@ export class MediaRequest {
notifyAdmin = false;
break;
case Notification.MEDIA_APPROVED:
- event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`;
+ event = `${
+ entity.is4k
+ ? entity.type === MediaType.BOOK
+ ? 'Audio'
+ : '4K '
+ : ''
+ }${mediaType} Request Approved`;
notifyAdmin = false;
break;
case Notification.MEDIA_DECLINED:
- event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`;
+ event = `${
+ entity.is4k
+ ? entity.type === MediaType.BOOK
+ ? 'Audio'
+ : '4K '
+ : ''
+ }${mediaType} Request Declined`;
notifyAdmin = false;
break;
case Notification.MEDIA_PENDING:
- event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`;
+ event = `New ${
+ entity.is4k
+ ? entity.type === MediaType.BOOK
+ ? 'Audio'
+ : '4K '
+ : ''
+ }${mediaType} Request`;
break;
case Notification.MEDIA_AUTO_REQUESTED:
event = `${
- entity.is4k ? '4K ' : ''
+ entity.is4k
+ ? entity.type === MediaType.BOOK
+ ? 'Audio '
+ : '4K '
+ : ''
}${mediaType} Request Automatically Submitted`;
notifyAdmin = false;
notifySystem = false;
break;
case Notification.MEDIA_AUTO_APPROVED:
event = `${
- entity.is4k ? '4K ' : ''
+ entity.is4k
+ ? entity.type === MediaType.BOOK
+ ? 'Audio'
+ : '4K '
+ : ''
}${mediaType} Request Automatically Approved`;
break;
case Notification.MEDIA_FAILED:
- event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`;
+ event = `${
+ entity.is4k
+ ? entity.type === MediaType.BOOK
+ ? 'Audio '
+ : '4K '
+ : ''
+ }${mediaType} Request Failed`;
break;
}
@@ -813,6 +920,27 @@ export class MediaRequest {
},
],
});
+ } else if (entity.type === MediaType.BOOK) {
+ const book = await hardcover.getBook(media.tmdbId);
+ notificationManager.sendNotification(type, {
+ media,
+ request: entity,
+ notifyAdmin,
+ notifySystem,
+ notifyUser: notifyAdmin ? undefined : entity.requestedBy,
+ event,
+ subject: `${book.title}${
+ book.release_date ? ` (${book.release_date.slice(0, 4)})` : ''
+ }`,
+ message: book.description
+ ? truncate(book.description, {
+ length: 500,
+ separator: /\s/,
+ omission: '…',
+ })
+ : 'No description available.',
+ image: book.image.url,
+ });
}
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts
index f77d721d48..5c6566e06c 100644
--- a/server/entity/OverrideRule.ts
+++ b/server/entity/OverrideRule.ts
@@ -17,6 +17,9 @@ class OverrideRule {
@Column({ type: 'int', nullable: true })
public sonarrServiceId?: number;
+ @Column({ type: 'int', nullable: true })
+ public readarrServiceId?: number;
+
@Column({ nullable: true })
public users?: string;
@@ -32,6 +35,9 @@ class OverrideRule {
@Column({ type: 'int', nullable: true })
public profileId?: number;
+ @Column({ type: 'int', nullable: true })
+ public metadataProfileId?: number;
+
@Column({ nullable: true })
public rootFolder?: string;
diff --git a/server/entity/User.ts b/server/entity/User.ts
index 739fff32ea..325285a1ba 100644
--- a/server/entity/User.ts
+++ b/server/entity/User.ts
@@ -51,6 +51,8 @@ export class User {
'settings',
];
+ static readonly serializationExcludedFields: string[] = ['settings'];
+
public displayName: string;
@PrimaryGeneratedColumn()
@@ -101,7 +103,14 @@ export class User {
@Column({ type: 'varchar', nullable: true, select: false })
public plexToken?: string | null;
- @Column({ type: 'integer', default: 0 })
+ @Column({
+ type: 'bigint',
+ default: 0,
+ transformer: {
+ to: (value: number) => value,
+ from: (value: string | number) => Number(value),
+ },
+ })
public permissions = 0;
@Column()
@@ -134,6 +143,12 @@ export class User {
@Column({ nullable: true })
public tvQuotaDays?: number;
+ @Column({ nullable: true })
+ public bookQuotaLimit?: number;
+
+ @Column({ nullable: true })
+ public bookQuotaDays?: number;
+
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@@ -173,6 +188,16 @@ export class User {
return filtered;
}
+ // settings is eager-loaded and would otherwise ride along into every nested User response
+ public toJSON(): Partial {
+ return Object.assign(
+ {},
+ ...(Object.keys(this) as (keyof User)[])
+ .filter((k) => !User.serializationExcludedFields.includes(k))
+ .map((k) => ({ [k]: this[k] }))
+ );
+ }
+
public hasPermission(
permissions: Permission | Permission[],
options?: PermissionCheckOptions
@@ -299,6 +324,7 @@ export class User {
...(movieQuotaDays ? { createdAt: AfterDate(movieDate) } : {}),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
+ ignoreQuota: false,
},
})
: 0;
@@ -336,6 +362,9 @@ export class User {
const tvQuotaUsed = tvQuotaLimit
? (
await tvQuotaUsedQuery
+ .andWhere('request.ignoreQuota = :ignoreQuota', {
+ ignoreQuota: false,
+ })
.addSelect((subQuery) => {
return subQuery
.select('COUNT(season.id)', 'seasonCount')
@@ -347,6 +376,41 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0;
+ const bookQuotaLimit = !canBypass
+ ? (this.bookQuotaLimit ?? defaultQuotas.book.quotaLimit)
+ : 0;
+ const bookQuotaDays = this.bookQuotaDays ?? defaultQuotas.book.quotaDays;
+
+ // Count book requests made during quota period
+ const bookDate = new Date();
+ if (bookQuotaDays) {
+ bookDate.setDate(bookDate.getDate() - bookQuotaDays);
+ }
+
+ // Ebook and audiobook requests for the same book are a single user action
+ // when format syncing is on, so quota counts distinct books rather than rows
+ const bookQuotaUsedQuery = requestRepository
+ .createQueryBuilder('request')
+ .leftJoin('request.requestedBy', 'requestedBy')
+ .where('request.type = :requestType', { requestType: MediaType.BOOK })
+ .andWhere('requestedBy.id = :userId', { userId: this.id })
+ .andWhere('request.status != :declinedStatus', {
+ declinedStatus: MediaRequestStatus.DECLINED,
+ });
+
+ if (bookQuotaDays) {
+ bookQuotaUsedQuery.andWhere('request.createdAt > :date', {
+ date: bookDate.toJSON(),
+ });
+ }
+
+ const bookQuotaUsed = bookQuotaLimit
+ ? await bookQuotaUsedQuery
+ .select('COUNT(DISTINCT request.mediaId)', 'count')
+ .getRawOne()
+ .then((result) => Number(result?.count ?? 0))
+ : 0;
+
return {
movie: {
days: movieQuotaDays,
@@ -368,6 +432,15 @@ export class User {
: undefined,
restricted: !!(tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0),
},
+ book: {
+ days: bookQuotaDays,
+ limit: bookQuotaLimit,
+ used: bookQuotaUsed,
+ remaining: bookQuotaLimit
+ ? Math.max(0, bookQuotaLimit - bookQuotaUsed)
+ : undefined,
+ restricted: !!(bookQuotaLimit && bookQuotaLimit - bookQuotaUsed <= 0),
+ },
};
}
}
diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts
index 82671fe3b3..d0f2ef1f7e 100644
--- a/server/entity/UserSettings.ts
+++ b/server/entity/UserSettings.ts
@@ -14,6 +14,19 @@ export const ALL_NOTIFICATIONS = Object.values(Notification)
.filter((v) => !isNaN(Number(v)))
.reduce((a, v) => a + Number(v), 0);
+// convert between DB representation (JSON string) into typescript array
+const jsonArrayTransformer = {
+ from: (v: string | null): string[] => {
+ try {
+ return v ? JSON.parse(v) : [];
+ } catch {
+ return [];
+ }
+ },
+ to: (v: string[] | null): string | null =>
+ v?.length ? JSON.stringify(v) : null,
+};
+
@Entity()
export class UserSettings {
constructor(init?: Partial) {
@@ -42,8 +55,8 @@ export class UserSettings {
@Column({ nullable: true })
public pgpKey?: string;
- @Column({ nullable: true })
- public discordId?: string;
+ @Column({ type: 'text', nullable: true, transformer: jsonArrayTransformer })
+ public discordIds: string[];
@Column({ nullable: true })
public pushbulletAccessToken?: string;
diff --git a/server/i18n/globalMessages.ts b/server/i18n/globalMessages.ts
index 38f53f0df2..106232d733 100644
--- a/server/i18n/globalMessages.ts
+++ b/server/i18n/globalMessages.ts
@@ -19,6 +19,8 @@ const globalMessages = defineMessages('notifications.common', {
openIn: 'Open in {applicationTitle}',
movie: 'movie',
series: 'series',
+ book: 'book',
+ audiobook: 'audiobook',
issue: 'issue',
issueTypeName: '{type} issue',
});
diff --git a/server/i18n/index.ts b/server/i18n/index.ts
index ad4fc6fcc2..d79c3f9569 100644
--- a/server/i18n/index.ts
+++ b/server/i18n/index.ts
@@ -1,4 +1,5 @@
import { createIntl, createIntlCache } from '@formatjs/intl';
+import { getSettings } from '@server/lib/settings';
import type { AvailableLocale } from '@server/types/languages';
import { availableLocales } from '@server/types/languages';
import fs from 'fs';
@@ -38,7 +39,10 @@ export function initI18n(): void {
}
export function getIntl(locale?: AvailableLocale): IntlInstance {
- return intls.get(locale ?? 'en') || intls.get('en')!;
+ // "Default" stores a falsy locale, so fall back to the server language, then English
+ const serverLocale = getSettings().main.locale as AvailableLocale;
+ const resolved = locale || serverLocale || 'en';
+ return intls.get(resolved) || intls.get('en')!;
}
type MessageDescriptorMap> = {
diff --git a/server/i18n/locale/ca.json b/server/i18n/locale/ca.json
index 0967ef424b..4d8b6c0b6d 100644
--- a/server/i18n/locale/ca.json
+++ b/server/i18n/locale/ca.json
@@ -1 +1,57 @@
-{}
+{
+ "notifications.agents.email.approvedRequest": "La teva sol·licitud de {mediaType} ha estat aprovada:",
+ "notifications.agents.email.approvedRequest4k": "La teva sol·licitud de {mediaType} en 4k ha estat aprovada:",
+ "notifications.agents.email.autoApproved": "S'ha aprovat automàticament les noves sol·licituds per la següent {mediaType}:",
+ "notifications.agents.email.autoApproved4k": "S'ha aprovat automàticament una nova sol·licitud per al següent {mediaType} en 4K:",
+ "notifications.agents.email.autoRequested": "S'ha enviat automàticament una nova sol·licitud per al següent {mediaType}:",
+ "notifications.agents.email.autoRequested4k": "S'ha enviat automàticament una nova sol·licitud per al següent {mediaType} en 4K:",
+ "notifications.agents.email.availableRequest": "La teva sol·licitud de {mediaType} ja està disponible:",
+ "notifications.agents.email.availableRequest4k": "La teva sol·licitud de {mediaType} en 4k ja està disponible:",
+ "notifications.agents.email.declinedRequest": "La teva sol·licitud de {mediaType} ha estat denegada:",
+ "notifications.agents.email.declinedRequest4k": "La teva sol·licitud de {mediaType} en 4k ha estat denegada:",
+ "notifications.agents.email.failedRequest": "La teva sol·licitud per {mediaType} no s'ha pogut afegir a {service}:",
+ "notifications.agents.email.issue": "incidència",
+ "notifications.common.available": "Disponible",
+ "notifications.common.declined": "Rebutjat",
+ "notifications.common.failed": "Fallit",
+ "notifications.common.issue": "incidència",
+ "notifications.common.movie": "pel·lícula",
+ "notifications.common.open": "Oberta",
+ "notifications.common.processing": "En procés",
+ "notifications.common.resolved": "Resolta",
+ "notifications.common.series": "sèrie",
+ "notifications.agents.email.issueType": "Incidència de {type}",
+ "notifications.agents.webpush.viewIssue": "Veure incidència",
+ "notifications.agents.webpush.viewMedia": "Veure contingut",
+ "notifications.common.issueStatus": "Estat de la incidència",
+ "notifications.common.pendingApproval": "Pendent d'aprovació",
+ "notifications.common.reportedBy": "Reportat per",
+ "notifications.agents.email.failedRequest4k": "No s'ha pogut afegir una sol·licitud per al següent {mediaType} en 4K a {service}:",
+ "notifications.agents.email.issueComment": "{userName} ha fet un comentari a la inciència de {issueType} per a {mediaType} {subject}:",
+ "notifications.agents.email.issueCreated": "{userName} ha informat d'un nou {issueType} per a {mediaType} {subject}:",
+ "notifications.agents.email.issueReopened": "La incidència de {issueType} per a {mediaType} {subject} ha estat reoberta per {userName}.",
+ "notifications.agents.email.issueResolved": "La incidència de {issueType} per a {mediaType} {subject} ha estat marcada com a resolta per {userName}!",
+ "notifications.agents.email.pendingRequest": "Hi ha una nova sol·licitud de {mediaType} pendent d'aprovació:",
+ "notifications.agents.email.pendingRequest4k": "Una nova sol·licitud de {mediaType} en 4K està pendent d'aprovació:",
+ "notifications.agents.webpush.approved": "La teva sol·licitud de {quality}{mediaType} ha estat aprovada.",
+ "notifications.agents.webpush.autoApproved": "S'ha aprovat automàticament una nova sol·licitud de {mediaType} {quality} de {userName}.",
+ "notifications.agents.webpush.autoRequested": "S'ha enviat automàticament una nova sol·licitud de {mediaType} {quality}.",
+ "notifications.agents.webpush.available": "La teva sol·licitud de {mediaType} {quality} ja està disponible!",
+ "notifications.agents.webpush.declined": "La teva sol·licitud de {mediaType} {quality} ha estat rebutjada.",
+ "notifications.agents.webpush.failed": "No s'ha pogut processar la sol·licitud de {mediaType} {quality}.",
+ "notifications.agents.webpush.issueComment": "{userName} ha fet un comentari en la incidència de {issueType}.",
+ "notifications.agents.webpush.issueCreated": "{userName} ha informat d'una nova incidència de ({issueType}).",
+ "notifications.agents.webpush.issueReopened": "{userName} ha reobert la incidència de {issueType}.",
+ "notifications.agents.webpush.issueResolved": "{userName} ha marcat la incidència de {issueType} com a resolta!",
+ "notifications.agents.webpush.pending": "Cal aprovació per a una nova sol·licitud de {mediaType} {quality} de {userName}.",
+ "notifications.common.commentFrom": "Comentari de {userName}",
+ "notifications.common.issueType": "Tipus d'incidència",
+ "notifications.common.issueTypeName": "Incidència de {type}",
+ "notifications.common.openIn": "Obre a {applicationTitle}",
+ "notifications.common.requestStatus": "Estat de la sol·licitud",
+ "notifications.common.requestedBy": "Sol·licitat per",
+ "notifications.common.viewIssue": "Veure incidència a {applicationTitle}",
+ "notifications.common.viewMedia": "Veure contingut multimèdia a {applicationTitle}",
+ "notifications.test.message": "Provant, 1, 2, 3. Es rep alt i clar?",
+ "notifications.test.subject": "Notificació de prova"
+}
diff --git a/server/i18n/locale/cs.json b/server/i18n/locale/cs.json
index 0967ef424b..d78ab2ed16 100644
--- a/server/i18n/locale/cs.json
+++ b/server/i18n/locale/cs.json
@@ -1 +1,57 @@
-{}
+{
+ "notifications.agents.email.approvedRequest": "Vaše žádost o následující {mediaType} byla schválena:",
+ "notifications.agents.email.approvedRequest4k": "Vaše žádost o následující {mediaType} ve 4K byla schválena:",
+ "notifications.agents.email.autoApproved": "Nová žádost o následující {mediaType} byla automaticky schválena:",
+ "notifications.agents.email.autoApproved4k": "Nová žádost o následující {mediaType} ve 4K byla automaticky schválena:",
+ "notifications.agents.email.autoRequested": "Nová žádost o následující {mediaType} byla automaticky odeslána:",
+ "notifications.agents.email.autoRequested4k": "Nová žádost o následující {mediaType} ve 4K byla automaticky odeslána:",
+ "notifications.agents.email.availableRequest": "Vaše žádost o následující {mediaType} je nyní dostupná:",
+ "notifications.agents.email.availableRequest4k": "Vaše žádost o následující {mediaType} ve 4K je nyní dostupná:",
+ "notifications.agents.email.declinedRequest": "Vaše žádost o následující {mediaType} byla zamítnuta:",
+ "notifications.agents.email.declinedRequest4k": "Vaše žádost o následující {mediaType} ve 4K byla zamítnuta:",
+ "notifications.agents.email.failedRequest": "Žádost o následující {mediaType} se nepodařilo přidat do {service}:",
+ "notifications.agents.email.failedRequest4k": "Žádost o následující {mediaType} ve 4K se nepodařilo přidat do {service}:",
+ "notifications.agents.email.issue": "problém",
+ "notifications.agents.email.issueComment": "{userName} okomentoval {issueType} pro {mediaType} {subject}:",
+ "notifications.agents.email.issueCreated": "Nový {issueType} byl nahlášen uživatelem {userName} pro {mediaType} {subject}:",
+ "notifications.agents.email.issueReopened": "{issueType} pro {mediaType} {subject} byl znovu otevřen uživatelem {userName}.",
+ "notifications.agents.email.issueResolved": "{issueType} pro {mediaType} {subject} byl označen jako vyřešený uživatelem {userName}!",
+ "notifications.agents.email.issueType": "{type} problém",
+ "notifications.agents.email.pendingRequest": "Nová žádost o následující {mediaType} čeká na schválení:",
+ "notifications.agents.email.pendingRequest4k": "Nová žádost o následující {mediaType} ve 4K čeká na schválení:",
+ "notifications.agents.webpush.approved": "Vaše žádost o {quality}{mediaType} byla schválena.",
+ "notifications.agents.webpush.autoApproved": "Automaticky schválená nová žádost o {quality}{mediaType} od uživatele {userName}.",
+ "notifications.agents.webpush.autoRequested": "Automaticky odeslaná nová žádost o {quality}{mediaType}.",
+ "notifications.agents.webpush.available": "Vaše žádost o {quality}{mediaType} je nyní dostupná!",
+ "notifications.agents.webpush.declined": "Vaše žádost o {quality}{mediaType} byla zamítnuta.",
+ "notifications.agents.webpush.failed": "Nepodařilo se zpracovat žádost o {quality}{mediaType}.",
+ "notifications.agents.webpush.issueComment": "{userName} okomentoval {issueType}.",
+ "notifications.agents.webpush.issueCreated": "Nový {issueType} byl nahlášen uživatelem {userName}.",
+ "notifications.agents.webpush.issueReopened": "{issueType} byl znovu otevřen uživatelem {userName}.",
+ "notifications.agents.webpush.issueResolved": "{issueType} byl označen jako vyřešený uživatelem {userName}!",
+ "notifications.agents.webpush.pending": "Je vyžadováno schválení pro novou žádost o {quality}{mediaType} od uživatele {userName}.",
+ "notifications.agents.webpush.viewIssue": "Zobrazit problém",
+ "notifications.agents.webpush.viewMedia": "Zobrazit médium",
+ "notifications.common.available": "Dostupné",
+ "notifications.common.commentFrom": "Komentář od uživatele {userName}",
+ "notifications.common.declined": "Zamítnuto",
+ "notifications.common.failed": "Selhalo",
+ "notifications.common.issue": "problém",
+ "notifications.common.issueStatus": "Stav problému",
+ "notifications.common.issueType": "Typ problému",
+ "notifications.common.issueTypeName": "{type} problém",
+ "notifications.common.movie": "film",
+ "notifications.common.open": "Otevřít",
+ "notifications.common.openIn": "Otevřít v {applicationTitle}",
+ "notifications.common.pendingApproval": "Čekající na schválení",
+ "notifications.common.processing": "Zpracovávání",
+ "notifications.common.reportedBy": "Nahlásil",
+ "notifications.common.requestStatus": "Stav žádosti",
+ "notifications.common.requestedBy": "Zažádal",
+ "notifications.common.resolved": "Vyřešeno",
+ "notifications.common.series": "seriál",
+ "notifications.common.viewIssue": "Zobrazit problém v {applicationTitle}",
+ "notifications.common.viewMedia": "Zobrazit médium v {applicationTitle}",
+ "notifications.test.message": "Kontrola, kontrola, 1, 2, 3. Jak nás slyšíte?",
+ "notifications.test.subject": "Testovací oznámení"
+}
diff --git a/server/i18n/locale/da.json b/server/i18n/locale/da.json
index cde7f88bea..d7351ec270 100644
--- a/server/i18n/locale/da.json
+++ b/server/i18n/locale/da.json
@@ -5,8 +5,8 @@
"notifications.agents.email.autoApproved4k": "En ny anmodning om følgende {mediaType} i 4K er automatisk blevet godkendt:",
"notifications.agents.email.autoRequested": "En ny anmodning om følgende {mediaType} blev automatisk oprettet:",
"notifications.agents.email.autoRequested4k": "En ny anmodning om følgende {mediaType} i 4K blev automatisk oprettet:",
- "notifications.agents.email.availableRequest": "Din anmodning om følgende {mediaType} er nu tilgængelig:",
- "notifications.agents.email.availableRequest4k": "Din anmodning om følgende {mediaType} i 4K er nu tilgængelig:",
+ "notifications.agents.email.availableRequest": "Din anmodede {mediaType} er nu tilgængelig:",
+ "notifications.agents.email.availableRequest4k": "Din anmodede {mediaType} i 4K er nu tilgængelig:",
"notifications.agents.email.declinedRequest": "Din anmodning om følgende {mediaType} blev afvist:",
"notifications.agents.email.declinedRequest4k": "Din anmodning om følgende {mediaType} i 4K blev afvist:",
"notifications.agents.email.failedRequest": "En anmodning om følgende {mediaType} kunne ikke tilføjes til {service}:",
diff --git a/server/i18n/locale/en.json b/server/i18n/locale/en.json
index 459ed5fada..2a686c0ba3 100644
--- a/server/i18n/locale/en.json
+++ b/server/i18n/locale/en.json
@@ -32,7 +32,9 @@
"notifications.agents.webpush.pending": "Approval required for a new {quality}{mediaType} request from {userName}.",
"notifications.agents.webpush.viewIssue": "View Issue",
"notifications.agents.webpush.viewMedia": "View Media",
+ "notifications.common.audiobook": "audiobook",
"notifications.common.available": "Available",
+ "notifications.common.book": "book",
"notifications.common.commentFrom": "Comment from {userName}",
"notifications.common.declined": "Declined",
"notifications.common.failed": "Failed",
diff --git a/server/i18n/locale/es-MX.json b/server/i18n/locale/es-MX.json
index 0967ef424b..fbe66fd29d 100644
--- a/server/i18n/locale/es-MX.json
+++ b/server/i18n/locale/es-MX.json
@@ -1 +1,57 @@
-{}
+{
+ "notifications.agents.email.approvedRequest": "Tu solicitud de {mediaType} ha sido aprobada:",
+ "notifications.agents.email.approvedRequest4k": "Tu solicitud de {mediaType} en 4K ha sido aprobada:",
+ "notifications.agents.email.autoApproved": "Una nueva solicitud de {mediaType} ha sido aprobada automáticamente:",
+ "notifications.agents.email.autoApproved4k": "Una nueva solicitud de {mediaType} en 4K ha sido aprobada automáticamente:",
+ "notifications.agents.email.autoRequested": "Una nueva solicitud de {mediaType} ha sido enviada automáticamente:",
+ "notifications.agents.email.autoRequested4k": "Una nueva solicitud de {mediaType} en 4K ha sido enviada automáticamente:",
+ "notifications.agents.email.availableRequest": "Tu solicitud de {mediaType} ya está disponible:",
+ "notifications.agents.email.availableRequest4k": "Tu solicitud de {mediaType} en 4K ya está disponible:",
+ "notifications.agents.email.declinedRequest": "Tu solicitud de {mediaType} ha sido rechazada:",
+ "notifications.agents.email.declinedRequest4k": "Tu solicitud de {mediaType} en 4K ha sido rechazada:",
+ "notifications.agents.email.failedRequest": "No se pudo agregar {mediaType} a {service}:",
+ "notifications.agents.email.failedRequest4k": "No se pudo agregar {mediaType} en 4K a {service}:",
+ "notifications.agents.email.issue": "problema",
+ "notifications.agents.email.issueComment": "{userName} comentó en el problema de {issueType} para {mediaType} {subject}:",
+ "notifications.agents.email.issueCreated": "{userName} reportó un nuevo {issueType} para {mediaType} {subject}:",
+ "notifications.agents.email.issueReopened": "El problema de {issueType} para {mediaType} {subject} fue reabierto por {userName}.",
+ "notifications.agents.email.issueResolved": "¡El problema de {issueType} para {mediaType} {subject} fue marcado como resuelto por {userName}!",
+ "notifications.agents.email.issueType": "Problema de {type}",
+ "notifications.agents.email.pendingRequest": "Una nueva solicitud de {mediaType} está pendiente de aprobación:",
+ "notifications.agents.email.pendingRequest4k": "Una nueva solicitud de {mediaType} en 4K está pendiente de aprobación:",
+ "notifications.agents.webpush.approved": "Tu solicitud de {mediaType} {quality} ha sido aprobada.",
+ "notifications.agents.webpush.autoApproved": "Se aprobó automáticamente una nueva solicitud de {mediaType} {quality} de {userName}.",
+ "notifications.agents.webpush.autoRequested": "Se envió automáticamente una nueva solicitud de {mediaType} {quality}.",
+ "notifications.agents.webpush.available": "¡Tu solicitud de {mediaType} {quality} ya está disponible!",
+ "notifications.agents.webpush.declined": "Tu solicitud de {mediaType} {quality} ha sido rechazada.",
+ "notifications.agents.webpush.failed": "No se pudo procesar la solicitud de {mediaType} {quality}.",
+ "notifications.agents.webpush.issueComment": "{userName} comentó en el problema de {issueType}.",
+ "notifications.agents.webpush.issueCreated": "{userName} reportó un nuevo problema de {issueType}.",
+ "notifications.agents.webpush.issueReopened": "El problema de {issueType} fue reabierto por {userName}.",
+ "notifications.agents.webpush.issueResolved": "¡El problema de {issueType} fue marcado como resuelto por {userName}!",
+ "notifications.agents.webpush.pending": "Se requiere aprobación para una nueva solicitud de {mediaType} {quality} de {userName}.",
+ "notifications.agents.webpush.viewIssue": "Ver problema",
+ "notifications.agents.webpush.viewMedia": "Ver contenido",
+ "notifications.common.available": "Disponible",
+ "notifications.common.commentFrom": "Comentario de {userName}",
+ "notifications.common.declined": "Rechazada",
+ "notifications.common.failed": "Fallida",
+ "notifications.common.issue": "Problema",
+ "notifications.common.issueStatus": "Estado del problema",
+ "notifications.common.issueType": "Tipo de problema",
+ "notifications.common.issueTypeName": "Problema de {type}",
+ "notifications.common.movie": "Película",
+ "notifications.common.open": "Abierto",
+ "notifications.common.openIn": "Abrir en {applicationTitle}",
+ "notifications.common.pendingApproval": "Pendiente de aprobación",
+ "notifications.common.processing": "Procesando",
+ "notifications.common.reportedBy": "Reportado por",
+ "notifications.common.requestStatus": "Estado de la solicitud",
+ "notifications.common.requestedBy": "Solicitado por",
+ "notifications.common.resolved": "Resuelto",
+ "notifications.common.series": "Serie",
+ "notifications.common.viewIssue": "Ver problema en {applicationTitle}",
+ "notifications.common.viewMedia": "Ver contenido en {applicationTitle}",
+ "notifications.test.message": "Probando, probando, 1, 2, 3. ¿Se escucha bien?",
+ "notifications.test.subject": "Notificación de prueba"
+}
diff --git a/server/i18n/locale/es.json b/server/i18n/locale/es.json
index 2e7428a0b4..7dd9f4c99e 100644
--- a/server/i18n/locale/es.json
+++ b/server/i18n/locale/es.json
@@ -19,5 +19,39 @@
"notifications.common.issueStatus": "Estado del problema",
"notifications.common.available": "Disponible",
"notifications.common.commentFrom": "Comentario de {userName}",
- "notifications.common.declined": "Declinado"
+ "notifications.common.declined": "Declinado",
+ "notifications.agents.email.approvedRequest": "Tu solicitud de {mediaType} ha sido aprobada:",
+ "notifications.agents.email.approvedRequest4k": "Tu solicitud de {mediaType} en 4K ha sido aprobada:",
+ "notifications.agents.email.autoApproved": "Una nueva solicitud de {mediaType} ha sido aprobada automáticamente:",
+ "notifications.agents.email.autoApproved4k": "Una nueva solicitud de {mediaType} en 4K ha sido aprobada automáticamente:",
+ "notifications.agents.email.autoRequested": "Se ha enviado automáticamente una nueva solicitud de {mediaType}:",
+ "notifications.agents.email.autoRequested4k": "Se ha enviado automáticamente una nueva solicitud de {mediaType} en 4K:",
+ "notifications.agents.email.availableRequest": "Tu solicitud de {mediaType} ya está disponible:",
+ "notifications.agents.email.availableRequest4k": "Tu solicitud de {mediaType} en 4K ya está disponible:",
+ "notifications.agents.email.declinedRequest": "Tu solicitud de {mediaType} ha sido rechazada:",
+ "notifications.agents.email.declinedRequest4k": "Tu solicitud de {mediaType} en 4K ha sido rechazada:",
+ "notifications.agents.email.failedRequest": "Ha fallado al añadir a {service} una solicitud de {mediaType}:",
+ "notifications.agents.email.failedRequest4k": "Ha fallado al añadir a {service} una solicitud de {mediaType} en 4K:",
+ "notifications.agents.email.issue": "incidencia",
+ "notifications.agents.email.issueComment": "{userName} ha comentado en la incidencia de {issueType} para {mediaType} {subject}:",
+ "notifications.agents.email.issueCreated": "{userName} ha reportado una nueva incidencia de {issueType} para {mediaType} {subject}:",
+ "notifications.agents.email.issueReopened": "La incidencia de {issueType} para {mediaType} {subject} ha sido reabierta por {userName}.",
+ "notifications.agents.email.issueResolved": "¡La incidencia de {issueType} para {mediaType} {subject} ha sido marcada como resuelta por {userName}!",
+ "notifications.agents.email.issueType": "Incidencia de {type}",
+ "notifications.agents.email.pendingRequest": "Hay una nueva solicitud de {mediaType} pendiente de aprobación:",
+ "notifications.agents.email.pendingRequest4k": "Hay una nueva solicitud de {mediaType} en 4K pendiente de aprobación:",
+ "notifications.agents.webpush.approved": "Tu solicitud de {mediaType} {quality} ha sido aprobada.",
+ "notifications.agents.webpush.autoApproved": "Se ha aprobado automáticamente una nueva solicitud de {mediaType} {quality} de {userName}.",
+ "notifications.agents.webpush.autoRequested": "Se ha enviado automáticamente una nueva solicitud de {mediaType} {quality}.",
+ "notifications.agents.webpush.available": "¡Tu solicitud de {mediaType} {quality} ya está disponible!",
+ "notifications.agents.webpush.declined": "Tu solicitud de {mediaType} {quality} ha sido rechazada.",
+ "notifications.agents.webpush.failed": "Error al procesar la solicitud de {mediaType} {quality}.",
+ "notifications.agents.webpush.issueComment": "{userName} ha comentado en la incidencia de {issueType}.",
+ "notifications.agents.webpush.issueCreated": "{userName} ha reportado una nueva incidencia de {issueType}.",
+ "notifications.agents.webpush.issueReopened": "La incidencia de {issueType} ha sido reabierta por {userName}.",
+ "notifications.agents.webpush.issueResolved": "¡La incidencia de {issueType} ha sido marcada como resuelta por {userName}!",
+ "notifications.agents.webpush.pending": "Se requiere aprobación para una nueva solicitud de {mediaType} {quality} de {userName}.",
+ "notifications.agents.webpush.viewIssue": "Ver incidencia",
+ "notifications.agents.webpush.viewMedia": "Ver contenido",
+ "notifications.common.issueTypeName": "Incidencia de {type}"
}
diff --git a/server/i18n/locale/fr.json b/server/i18n/locale/fr.json
index 58fb98478d..d6b5d66e21 100644
--- a/server/i18n/locale/fr.json
+++ b/server/i18n/locale/fr.json
@@ -21,17 +21,17 @@
"notifications.agents.email.pendingRequest4k": "Une nouvelle demande de {mediaType} en 4K est en attente de validation :",
"notifications.test.subject": "Notification de test",
"notifications.test.message": "Test, un, deux, un, deux. Vous nous recevez ?",
- "notifications.agents.webpush.approved": "Votre demande de {mediaType} {quality} a été validée.",
- "notifications.agents.webpush.autoApproved": "Une nouvelle demande de {mediaType} {quality} de {userName} a été validée automatiquement.",
- "notifications.agents.webpush.autoRequested": "Une nouvelle demande de {mediaType} {quality} a été créée automatiquement.",
- "notifications.agents.webpush.available": "Votre demande de {mediaType} {quality} est maintenant disponible !",
- "notifications.agents.webpush.declined": "Votre demande de {mediaType} {quality} a été refusée.",
- "notifications.agents.webpush.failed": "Impossible de traiter la demande de {mediaType} {quality}.",
+ "notifications.agents.webpush.approved": "Votre demande de {mediaType}{quality} a été validée.",
+ "notifications.agents.webpush.autoApproved": "Une nouvelle demande de {mediaType}{quality} par {userName} a été validée automatiquement.",
+ "notifications.agents.webpush.autoRequested": "Une nouvelle demande de {mediaType}{quality} a été créée automatiquement.",
+ "notifications.agents.webpush.available": "Votre demande de {mediaType}{quality} est maintenant disponible !",
+ "notifications.agents.webpush.declined": "Votre demande de {mediaType}{quality} a été refusée.",
+ "notifications.agents.webpush.failed": "Impossible de traiter la demande de {mediaType}{quality}.",
"notifications.agents.webpush.issueComment": "{userName} a commenté le {issueType}.",
"notifications.agents.webpush.issueCreated": "Un nouveau {issueType} a été signalé par {userName}.",
"notifications.agents.webpush.issueReopened": "Le {issueType} a été rouvert par {userName}.",
"notifications.agents.webpush.issueResolved": "Le {issueType} a été marqué comme résolu par {userName} !",
- "notifications.agents.webpush.pending": "Validation requise pour une nouvelle demande de {mediaType} {quality} de {userName}.",
+ "notifications.agents.webpush.pending": "Validation requise pour une nouvelle demande de {mediaType}{quality} par {userName}.",
"notifications.agents.webpush.viewIssue": "Voir le problème",
"notifications.agents.webpush.viewMedia": "Voir le média",
"notifications.common.available": "Disponible",
diff --git a/server/i18n/locale/nl.json b/server/i18n/locale/nl.json
index 7c40158188..4eb0573661 100644
--- a/server/i18n/locale/nl.json
+++ b/server/i18n/locale/nl.json
@@ -1,35 +1,35 @@
{
"notifications.agents.email.approvedRequest": "Je aanvraag voor de volgende {mediaType} is goedgekeurd:",
"notifications.agents.email.approvedRequest4k": "Je aanvraag voor de volgende {mediaType} in 4K is goedgekeurd:",
- "notifications.agents.email.autoApproved": "Een nieuw verzoek voor de volgende {mediaType} is automatisch goedgekeurd:",
- "notifications.agents.email.autoApproved4k": "Een nieuw verzoek voor de volgende {mediaType} in 4K is automatisch goedgekeurd:",
- "notifications.agents.email.autoRequested": "Een nieuw verzoek voor de volgende {mediaType} is automatisch ingediend:",
- "notifications.agents.email.autoRequested4k": "Een nieuw verzoek voor de volgende {mediaType} in 4K is automatisch ingediend:",
+ "notifications.agents.email.autoApproved": "Een nieuwe aanvraag voor de volgende {mediaType} is automatisch goedgekeurd:",
+ "notifications.agents.email.autoApproved4k": "Een nieuwe aanvraag voor de volgende {mediaType} in 4K is automatisch goedgekeurd:",
+ "notifications.agents.email.autoRequested": "Een nieuwe aanvraag voor de volgende {mediaType} is automatisch ingediend:",
+ "notifications.agents.email.autoRequested4k": "Een nieuwe aanvraag voor de volgende {mediaType} in 4K is automatisch ingediend:",
"notifications.agents.email.availableRequest": "Je aanvraag voor de volgende {mediaType} is nu beschikbaar:",
"notifications.agents.email.availableRequest4k": "Je aanvraag voor de volgende {mediaType} in 4K is nu beschikbaar:",
- "notifications.agents.email.declinedRequest": "Je verzoek voor de volgende {mediaType} is afgewezen:",
- "notifications.agents.email.declinedRequest4k": "Je verzoek voor de volgende {mediaType} in 4K is afgewezen:",
- "notifications.agents.email.failedRequest": "Een verzoek voor de volgende {mediaType} kon niet worden toegevoegd aan {service}:",
- "notifications.agents.email.failedRequest4k": "Een verzoek voor de volgende {mediaType} in 4K kon niet worden toegevoegd aan {service}:",
+ "notifications.agents.email.declinedRequest": "Je aanvraag voor de volgende {mediaType} is afgewezen:",
+ "notifications.agents.email.declinedRequest4k": "Je aanvraag voor de volgende {mediaType} in 4K is afgewezen:",
+ "notifications.agents.email.failedRequest": "Een aanvraag voor de volgende {mediaType} kon niet worden toegevoegd aan {service}:",
+ "notifications.agents.email.failedRequest4k": "Een aanvraag voor de volgende {mediaType} in 4K kon niet worden toegevoegd aan {service}:",
"notifications.agents.email.issue": "probleem",
"notifications.agents.email.issueComment": "{userName} heeft gereageerd op het {issueType} voor de {mediaType} {subject}:",
"notifications.agents.email.issueCreated": "Er is een nieuw {issueType} gemeld door {userName} voor de {mediaType} {subject}:",
"notifications.agents.email.issueReopened": "Het {issueType} voor de {mediaType} {subject} is heropend door {userName}.",
"notifications.agents.email.issueResolved": "Het {issueType} voor de {mediaType} {subject} is als opgelost gemarkeerd door {userName}!",
"notifications.agents.email.issueType": "{type} probleem",
- "notifications.agents.email.pendingRequest": "Een nieuw verzoek voor de volgende {mediaType} wacht op goedkeuring:",
- "notifications.agents.email.pendingRequest4k": "Een nieuw verzoek voor de volgende {mediaType} in 4K wacht op goedkeuring:",
- "notifications.agents.webpush.approved": "Je verzoek voor {quality}{mediaType} is goedgekeurd.",
- "notifications.agents.webpush.autoApproved": "Automatisch een nieuw {quality}{mediaType}-verzoek van {userName} goedgekeurd.",
- "notifications.agents.webpush.autoRequested": "Automatisch een nieuw {quality}{mediaType}-verzoek ingediend.",
- "notifications.agents.webpush.available": "Je {quality}{mediaType}-verzoek is nu beschikbaar!",
- "notifications.agents.webpush.declined": "Je {quality}{mediaType}-verzoek is afgewezen.",
- "notifications.agents.webpush.failed": "Het verwerken van het {quality}{mediaType}-verzoek is mislukt.",
+ "notifications.agents.email.pendingRequest": "Een nieuwe aanvraag voor de volgende {mediaType} wacht op goedkeuring:",
+ "notifications.agents.email.pendingRequest4k": "Een nieuwe aanvraag voor de volgende {mediaType} in 4K wacht op goedkeuring:",
+ "notifications.agents.webpush.approved": "Je aanvraag voor {quality}{mediaType} is goedgekeurd.",
+ "notifications.agents.webpush.autoApproved": "Automatisch een nieuwe {quality}{mediaType}-aanvraag van {userName} goedgekeurd.",
+ "notifications.agents.webpush.autoRequested": "Automatisch een nieuwe {quality}{mediaType}-aanvraag ingediend.",
+ "notifications.agents.webpush.available": "Je {quality}{mediaType}-aanvraag is nu beschikbaar!",
+ "notifications.agents.webpush.declined": "Je {quality}{mediaType}-aanvraag is afgewezen.",
+ "notifications.agents.webpush.failed": "Het verwerken van het {quality}{mediaType}-aanvraag is mislukt.",
"notifications.agents.webpush.issueComment": "{userName} heeft gereageerd op het {issueType}.",
"notifications.agents.webpush.issueCreated": "Er is een nieuw {issueType} gemeld door {userName}.",
"notifications.agents.webpush.issueReopened": "Het {issueType} is heropend door {userName}.",
"notifications.agents.webpush.issueResolved": "Het {issueType} is als opgelost gemarkeerd door {userName}!",
- "notifications.agents.webpush.pending": "Goedkeuring vereist voor een nieuw {quality}{mediaType}-verzoek van {userName}.",
+ "notifications.agents.webpush.pending": "Goedkeuring vereist voor een nieuwe {quality}{mediaType}-aanvraag van {userName}.",
"notifications.agents.webpush.viewIssue": "Probleem bekijken",
"notifications.agents.webpush.viewMedia": "Media bekijken",
"notifications.common.available": "Beschikbaar",
@@ -37,16 +37,16 @@
"notifications.common.declined": "Afgewezen",
"notifications.common.failed": "Mislukt",
"notifications.common.issue": "probleem",
- "notifications.common.issueStatus": "Status van het probleem",
- "notifications.common.issueType": "Type probleem",
+ "notifications.common.issueStatus": "Probleemstatus",
+ "notifications.common.issueType": "Probleemtype",
"notifications.common.issueTypeName": "{type} probleem",
"notifications.common.movie": "film",
- "notifications.common.open": "Open",
- "notifications.common.openIn": "Open in {applicationTitle}",
+ "notifications.common.open": "Openen",
+ "notifications.common.openIn": "Openen in {applicationTitle}",
"notifications.common.pendingApproval": "In afwachting van goedkeuring",
"notifications.common.processing": "Verwerken",
"notifications.common.reportedBy": "Gemeld door",
- "notifications.common.requestStatus": "Status van verzoek",
+ "notifications.common.requestStatus": "Aanvraagstatus",
"notifications.common.requestedBy": "Aangevraagd door",
"notifications.common.resolved": "Opgelost",
"notifications.common.series": "serie",
diff --git a/server/i18n/locale/ru.json b/server/i18n/locale/ru.json
index 9d332b30d2..f188fb09c8 100644
--- a/server/i18n/locale/ru.json
+++ b/server/i18n/locale/ru.json
@@ -19,5 +19,39 @@
"notifications.agents.email.issueType": "Проблема {type}",
"notifications.agents.email.pendingRequest": "Новый запрос на {mediaType} ожидает одобрения:",
"notifications.agents.email.pendingRequest4k": "Новый запрос на {mediaType} в 4K ожидает одобрения:",
- "notifications.agents.webpush.approved": "Ваш {quality}{mediaType} был одобрен."
+ "notifications.agents.webpush.approved": "Ваш {quality}{mediaType} был одобрен.",
+ "notifications.agents.webpush.autoRequested": "Автоматически отправил новый запрос на {quality}{mediaType}.",
+ "notifications.agents.webpush.available": "Ваш запрос на {quality}{mediaType} теперь доступен!",
+ "notifications.agents.webpush.declined": "Ваш запрос на {quality}{mediaType} был отклонён.",
+ "notifications.agents.webpush.failed": "Не удалось обработать запрос на {quality}{mediaType}.",
+ "notifications.agents.webpush.issueComment": "{userName} оставил комментарий к {issueType}.",
+ "notifications.agents.webpush.issueCreated": "Новый {issueType} был отправлен пользователем {userName}.",
+ "notifications.agents.webpush.issueReopened": "{issueType} был повторно открыт пользователем {userName}.",
+ "notifications.agents.webpush.issueResolved": "{issueType} был помечен как решён пользователем {userName}!",
+ "notifications.agents.webpush.pending": "Требуется подтверждение нового запроса на {quality}{mediaType} от {userName}.",
+ "notifications.agents.webpush.viewIssue": "Просмотреть обращение",
+ "notifications.agents.webpush.viewMedia": "Просмотреть медиа",
+ "notifications.common.available": "Доступно",
+ "notifications.common.commentFrom": "Комментарий от {userName}",
+ "notifications.common.declined": "Отклонено",
+ "notifications.common.failed": "Не удалось",
+ "notifications.common.issue": "обращение",
+ "notifications.common.issueStatus": "Статус Обращения",
+ "notifications.common.issueType": "Тип Обращения",
+ "notifications.common.issueTypeName": "{type} обращения",
+ "notifications.common.movie": "фильм",
+ "notifications.common.open": "Открыть",
+ "notifications.common.openIn": "Открыть в {applicationTitle}",
+ "notifications.common.pendingApproval": "Ожидает подтверждения",
+ "notifications.common.processing": "Обработка",
+ "notifications.common.reportedBy": "Сообщено",
+ "notifications.common.requestStatus": "Статус Запроса",
+ "notifications.common.requestedBy": "Запрошено",
+ "notifications.common.resolved": "Разрешено",
+ "notifications.common.series": "серии",
+ "notifications.common.viewIssue": "Просмотреть обращение в {applicationTitle}",
+ "notifications.common.viewMedia": "Просмотреть медиа в {applicationTitle}",
+ "notifications.test.message": "Проверка связи, раз, два, три. Как слышно?",
+ "notifications.test.subject": "Тестовое уведомление",
+ "notifications.agents.webpush.autoApproved": "Автоматически одобрен новый запрос на {quality}{mediaType} от {userName}."
}
diff --git a/server/i18n/locale/tr.json b/server/i18n/locale/tr.json
index 0967ef424b..3d82035d48 100644
--- a/server/i18n/locale/tr.json
+++ b/server/i18n/locale/tr.json
@@ -1 +1,57 @@
-{}
+{
+ "notifications.agents.email.approvedRequest": "Aşağıdaki {mediaType} talebiniz onaylandı:",
+ "notifications.agents.email.approvedRequest4k": "Aşağıdaki {mediaType} için 4K talebiniz onaylandı:",
+ "notifications.agents.email.autoApproved": "Aşağıdaki {mediaType} için yeni bir talep otomatik olarak onaylandı:",
+ "notifications.agents.email.autoApproved4k": "Aşağıdaki {mediaType} için 4K'da yeni bir talep otomatik olarak onaylandı:",
+ "notifications.agents.email.autoRequested": "Aşağıdaki {mediaType} için yeni bir talep otomatik olarak gönderildi:",
+ "notifications.agents.email.autoRequested4k": "Aşağıdaki {mediaType} için 4K'da yeni bir talep otomatik olarak gönderildi:",
+ "notifications.agents.email.availableRequest": "Aşağıdaki {mediaType} için talebiniz artık kullanıma hazır:",
+ "notifications.agents.email.availableRequest4k": "Aşağıdaki {mediaType} için 4K talebiniz artık kullanıma hazır:",
+ "notifications.agents.email.declinedRequest": "Aşağıdaki {mediaType} için talebiniz reddedildi:",
+ "notifications.agents.email.declinedRequest4k": "Aşağıdaki {mediaType} için 4K talebiniz reddedildi:",
+ "notifications.agents.email.failedRequest": "Aşağıdaki {mediaType} için talep {service} hizmetine eklenemedi:",
+ "notifications.agents.email.failedRequest4k": "Aşağıdaki {mediaType} için 4K talebi {service} hizmetine eklenemedi:",
+ "notifications.agents.email.issue": "sorun",
+ "notifications.agents.email.issueComment": "{userName}, {mediaType} {subject} için bildirilen {issueType} hakkında yorum yaptı:",
+ "notifications.agents.email.issueCreated": "{userName} tarafından {mediaType} {subject} için yeni bir {issueType} bildirildi:",
+ "notifications.agents.email.issueReopened": "{mediaType} {subject} için bildirilen {issueType}, {userName} tarafından yeniden açıldı.",
+ "notifications.agents.email.issueResolved": "{mediaType} {subject} için bildirilen {issueType}, {userName} tarafından çözüldü olarak işaretlendi!",
+ "notifications.agents.email.issueType": "{type} sorunu",
+ "notifications.agents.email.pendingRequest": "Aşağıdaki {mediaType} için yeni bir talep onay bekliyor:",
+ "notifications.agents.email.pendingRequest4k": "Aşağıdaki {mediaType} için 4K'da yeni bir talep onay bekliyor:",
+ "notifications.agents.webpush.approved": "{quality}{mediaType} talebiniz onaylandı.",
+ "notifications.agents.webpush.autoApproved": "{userName} kullanıcısından gelen yeni bir {quality}{mediaType} talebi otomatik olarak onaylandı.",
+ "notifications.agents.webpush.autoRequested": "Yeni bir {quality}{mediaType} talebi otomatik olarak gönderildi.",
+ "notifications.agents.webpush.available": "{quality}{mediaType} talebiniz artık kullanıma hazır!",
+ "notifications.agents.webpush.declined": "{quality}{mediaType} talebiniz reddedildi.",
+ "notifications.agents.webpush.failed": "{quality}{mediaType} talebi işlenemedi.",
+ "notifications.agents.webpush.issueComment": "{userName}, {issueType} hakkında yorum yaptı.",
+ "notifications.agents.webpush.issueCreated": "{userName} tarafından yeni bir {issueType} bildirildi.",
+ "notifications.agents.webpush.issueReopened": "{issueType}, {userName} tarafından yeniden açıldı.",
+ "notifications.agents.webpush.issueResolved": "{issueType}, {userName} tarafından çözüldü olarak işaretlendi!",
+ "notifications.agents.webpush.pending": "{userName} kullanıcısından gelen yeni bir {quality}{mediaType} talebi için onay gerekiyor.",
+ "notifications.agents.webpush.viewIssue": "Sorunu Görüntüle",
+ "notifications.agents.webpush.viewMedia": "Medyayı Görüntüle",
+ "notifications.common.available": "Kullanılabilir",
+ "notifications.common.commentFrom": "{userName} kullanıcısından yorum",
+ "notifications.common.declined": "Reddedildi",
+ "notifications.common.failed": "Başarısız",
+ "notifications.common.issue": "sorun",
+ "notifications.common.issueStatus": "Sorun Durumu",
+ "notifications.common.issueType": "Sorun Türü",
+ "notifications.common.issueTypeName": "{type} sorunu",
+ "notifications.common.movie": "film",
+ "notifications.common.open": "Aç",
+ "notifications.common.openIn": "{applicationTitle} uygulamasında aç",
+ "notifications.common.pendingApproval": "Onay Bekliyor",
+ "notifications.common.processing": "İşleniyor",
+ "notifications.common.reportedBy": "Bildiren",
+ "notifications.common.requestStatus": "Talep Durumu",
+ "notifications.common.requestedBy": "Talep Eden",
+ "notifications.common.resolved": "Çözüldü",
+ "notifications.common.series": "dizi",
+ "notifications.common.viewIssue": "{applicationTitle} uygulamasında sorunu görüntüle",
+ "notifications.common.viewMedia": "{applicationTitle} uygulamasında medyayı görüntüle",
+ "notifications.test.message": "Ses testi, 1, 2, 3. Bizi duyuyor musunuz?",
+ "notifications.test.subject": "Test Bildirimi"
+}
diff --git a/server/i18n/locale/uk.json b/server/i18n/locale/uk.json
index 0967ef424b..60f162e08c 100644
--- a/server/i18n/locale/uk.json
+++ b/server/i18n/locale/uk.json
@@ -1 +1,27 @@
-{}
+{
+ "notifications.agents.email.approvedRequest": "Ваш запит на такий {mediaType} схвалено:",
+ "notifications.agents.email.approvedRequest4k": "Ваш запит на такий {mediaType} в 4K схвалено:",
+ "notifications.agents.email.autoApproved": "Новий запит для наступного {mediaType} було автоматично схвалено:",
+ "notifications.agents.email.autoApproved4k": "Новий запит для наступного {mediaType} в 4K було автоматично схвалено:",
+ "notifications.agents.email.autoRequested": "Новий запит для наступного {mediaType} було автоматично надіслано:",
+ "notifications.agents.email.autoRequested4k": "Новий запит для наступного {mediaType} в 4K було автоматично надіслано:",
+ "notifications.agents.email.availableRequest": "Ваш запит на {mediaType} тепер доступний:",
+ "notifications.agents.email.availableRequest4k": "Ваш запит на {mediaType} в 4K тепер доступний:",
+ "notifications.agents.email.declinedRequest": "Ваш запит на {mediaType} було відхилено:",
+ "notifications.agents.email.declinedRequest4k": "Ваш запит на {mediaType} в 4K було відхилено:",
+ "notifications.agents.email.failedRequest": "Не вдалося додати запит на {mediaType} до {service}:",
+ "notifications.agents.email.failedRequest4k": "Не вдалося додати запит на {mediaType} в 4K до {service}:",
+ "notifications.agents.email.issue": "питання",
+ "notifications.agents.email.issueComment": "{userName} прокоментував {issueType} для {mediaType} {subject}:",
+ "notifications.agents.email.issueCreated": "Користувач {userName} повідомив про новий {issueType} для {mediaType} {subject}:",
+ "notifications.agents.email.issueReopened": "{issueType} для {mediaType} {subject} було повторно відкрито користувачем {userName}.",
+ "notifications.agents.email.issueResolved": "Користувач {userName} позначив як вирішену проблему {issueType} для теми {mediaType} {subject}!",
+ "notifications.agents.email.issueType": "проблема {тип}",
+ "notifications.agents.email.pendingRequest": "Новий запит для наступного {mediaType} очікує на схвалення:",
+ "notifications.agents.email.pendingRequest4k": "Новий запит для наступного {mediaType} в 4K очікує на схвалення:",
+ "notifications.agents.webpush.approved": "Ваш запит на {quality}{mediaType} схвалено.",
+ "notifications.agents.webpush.autoApproved": "Автоматично схвалено новий запит {quality}{mediaType} від {userName}.",
+ "notifications.agents.webpush.autoRequested": "Автоматично надіслано новий запит {quality}{mediaType}.",
+ "notifications.agents.webpush.available": "Ваш запит на {quality}{mediaType} тепер доступний!",
+ "notifications.agents.webpush.declined": "Ваш запит {quality}{mediaType} було відхилено."
+}
diff --git a/server/index.ts b/server/index.ts
index b27b517ea2..49b30ddf8b 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1,11 +1,12 @@
import csurf from '@dr.pogodin/csurf';
import PlexAPI from '@server/api/plexapi';
-import dataSource, { getRepository, isPgsql } from '@server/datasource';
+import dataSource, { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { initI18n } from '@server/i18n';
import { startJobs } from '@server/job/schedule';
+import { applyForkSchema } from '@server/lib/forkSchema';
import notificationManager from '@server/lib/notifications';
import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
@@ -26,11 +27,15 @@ import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
-import createCustomProxyAgent from '@server/utils/customProxyAgent';
+import createCustomProxyAgent, {
+ setForceIpv4First,
+} from '@server/utils/customProxyAgent';
+import { isPgsql } from '@server/utils/dbType';
+import { initDemoData } from '@server/utils/demoMode';
import { initializeDnsCache } from '@server/utils/dnsCache';
import restartFlag from '@server/utils/restartFlag';
+import '@server/utils/userAgent';
import { getClientIp } from '@supercharge/request-ip';
-import axios from 'axios';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import type { NextFunction, Request, Response } from 'express';
@@ -39,8 +44,6 @@ import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import fs from 'fs/promises';
-import http from 'http';
-import https from 'https';
import yaml from 'js-yaml';
import next from 'next';
import path from 'path';
@@ -70,12 +73,12 @@ app
: await dataSource.initialize();
// Run migrations in production
- if (process.env.NODE_ENV === 'production') {
+ if (process.env.NODE_ENV === 'production' || isPgsql) {
if (isPgsql) {
- await dbConnection.runMigrations();
+ await applyForkSchema(dbConnection);
} else {
await dbConnection.query('PRAGMA foreign_keys=OFF');
- await dbConnection.runMigrations();
+ await applyForkSchema(dbConnection);
await dbConnection.query('PRAGMA foreign_keys=ON');
}
}
@@ -86,10 +89,7 @@ app
initI18n();
- if (settings.network.forceIpv4First) {
- axios.defaults.httpAgent = new http.Agent({ family: 4 });
- axios.defaults.httpsAgent = new https.Agent({ family: 4 });
- }
+ setForceIpv4First(settings.network.forceIpv4First);
// Add DNS caching
if (settings.network.dnsCache?.enabled) {
@@ -124,7 +124,17 @@ app
});
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
- await plexapi.syncLibraries();
+
+ try {
+ await plexapi.syncLibraries();
+ } catch {
+ // Leave the existing libraries untouched so the migration retries on
+ // the next startup instead of discarding the user's configuration
+ logger.warn(
+ 'Failed to migrate Plex libraries; will retry on next startup',
+ { label: 'Settings' }
+ );
+ }
}
}
@@ -245,6 +255,13 @@ app
};
next();
});
+
+ // Init demo mode and catch some API routes
+ if (process.env.UNSAFE_DO_NOT_USE_DEMO === 'true') {
+ logger.info('Demo mode enabled, seeding database with demo data');
+ await initDemoData(server);
+ }
+
server.use('/api/v1', routes);
// Do not set cookies so CDNs can cache them
@@ -257,10 +274,11 @@ app
err: { status: number; message: string; errors: string[] },
_req: Request,
res: Response,
- // We must provide a next function for the function signature here even though its not used
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- _next: NextFunction
+ next: NextFunction
) => {
+ if (res.headersSent) {
+ return next(err);
+ }
// format error
res.status(err.status || 500).json({
message: err.message,
@@ -271,19 +289,27 @@ app
const port = Number(process.env.PORT) || 5055;
const host = process.env.HOST;
+ let httpServer;
if (host) {
- server.listen(port, host, () => {
+ httpServer = server.listen(port, host, () => {
logger.info(`Server ready on ${host} port ${port}`, {
label: 'Server',
});
});
} else {
- server.listen(port, () => {
+ httpServer = server.listen(port, () => {
logger.info(`Server ready on port ${port}`, {
label: 'Server',
});
});
}
+ httpServer.on('error', (err) => {
+ logger.error('Failed to start server', {
+ label: 'Server',
+ message: err.message,
+ });
+ process.exit(1);
+ });
})
.catch((err) => {
logger.error(err.stack);
diff --git a/server/interfaces/api/blocklistInterfaces.ts b/server/interfaces/api/blocklistInterfaces.ts
index 58e3ebe474..ae1512feee 100644
--- a/server/interfaces/api/blocklistInterfaces.ts
+++ b/server/interfaces/api/blocklistInterfaces.ts
@@ -3,7 +3,7 @@ import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlocklistItem {
tmdbId: number;
- mediaType: 'movie' | 'tv';
+ mediaType: 'movie' | 'tv' | 'book';
title?: string;
createdAt?: Date;
user?: User;
diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts
index 6738cbb5fd..5f60ac6cae 100644
--- a/server/interfaces/api/discoverInterfaces.ts
+++ b/server/interfaces/api/discoverInterfaces.ts
@@ -8,7 +8,7 @@ export interface WatchlistItem {
id: number;
ratingKey: string;
tmdbId: number;
- mediaType: 'movie' | 'tv';
+ mediaType: 'movie' | 'tv' | 'book';
title: string;
}
diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts
index e5b3643cd0..136807d56f 100644
--- a/server/interfaces/api/issueInterfaces.ts
+++ b/server/interfaces/api/issueInterfaces.ts
@@ -1,6 +1,16 @@
+import type { IssueType } from '@server/constants/issue';
import type Issue from '@server/entity/Issue';
import type { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];
}
+
+export type IssueRequestBody = {
+ message: string;
+ mediaId: number;
+ issueType: IssueType;
+ problemSeason?: number;
+ problemEpisode?: number;
+ userId?: number;
+};
diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts
index 9b9c629954..a7e2dceca6 100644
--- a/server/interfaces/api/requestInterfaces.ts
+++ b/server/interfaces/api/requestInterfaces.ts
@@ -5,6 +5,7 @@ import type { NonFunctionProperties, PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse {
results: (NonFunctionProperties & {
profileName?: string;
+ metadataProfileName?: string;
canRemove?: boolean;
})[];
serviceErrors: {
@@ -21,9 +22,11 @@ export type MediaRequestBody = {
is4k?: boolean;
serverId?: number;
profileId?: number;
+ metadataProfileId?: number;
profileName?: string;
rootFolder?: string;
languageProfileId?: number;
userId?: number;
tags?: number[];
+ ignoreQuota?: boolean;
};
diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts
index 3b430b0b59..53b9b86868 100644
--- a/server/interfaces/api/serviceInterfaces.ts
+++ b/server/interfaces/api/serviceInterfaces.ts
@@ -1,4 +1,5 @@
import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base';
+import type { MetadataProfile } from '@server/api/servarr/readarr';
import type { LanguageProfile } from '@server/api/servarr/sonarr';
export interface ServiceCommonServer {
@@ -12,6 +13,7 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
+ activeMetadataProfileId?: number;
activeTags: number[];
activeAnimeTags?: number[];
}
@@ -21,5 +23,6 @@ export interface ServiceCommonServerWithDetails {
profiles: QualityProfile[];
rootFolders: Partial[];
languageProfiles?: LanguageProfile[];
+ metadataProfiles?: MetadataProfile[];
tags: Tag[];
}
diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts
index 3dfa619205..f4e1a2c4a0 100644
--- a/server/interfaces/api/settingsInterfaces.ts
+++ b/server/interfaces/api/settingsInterfaces.ts
@@ -31,16 +31,20 @@ export interface PublicSettingsResponse {
applicationUrl: string;
hideAvailable: boolean;
hideBlocklisted: boolean;
+ hideRequested: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
+ bookAudioEnabled: boolean;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
+ syncBookFormatRequests: boolean;
+ seriesRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
@@ -48,6 +52,7 @@ export interface PublicSettingsResponse {
emailEnabled: boolean;
newPlexLogin: boolean;
youtubeUrl: string;
+ versionCheck: boolean;
plexClientIdentifier: string;
}
@@ -65,7 +70,10 @@ export interface CacheItem {
export interface CacheResponse {
apiCaches: CacheItem[];
- imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
+ imageCache: Record<
+ 'tmdb' | 'avatar' | 'hardcover',
+ { size: number; imageCount: number }
+ >;
dnsCache: {
stats: DnsStats | undefined;
entries: DnsEntries | undefined;
@@ -75,7 +83,7 @@ export interface CacheResponse {
export interface StatusResponse {
version: string;
commitTag: string;
- updateAvailable: boolean;
- commitsBehind: number;
+ updateAvailable?: boolean;
+ commitsBehind?: number;
restartRequired: boolean;
}
diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts
index 2ac75c5e1f..3503c9f02f 100644
--- a/server/interfaces/api/userInterfaces.ts
+++ b/server/interfaces/api/userInterfaces.ts
@@ -22,6 +22,7 @@ export interface QuotaStatus {
export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
+ book: QuotaStatus;
}
export interface UserWatchDataResponse {
diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts
index 327764618e..ad4f098d1e 100644
--- a/server/interfaces/api/userSettingsInterfaces.ts
+++ b/server/interfaces/api/userSettingsInterfaces.ts
@@ -3,7 +3,6 @@ import type { NotificationAgentKey } from '@server/lib/settings';
export interface UserSettingsGeneralResponse {
username?: string;
email?: string;
- discordId?: string;
locale?: string;
discoverRegion?: string;
streamingRegion?: string;
@@ -12,10 +11,14 @@ export interface UserSettingsGeneralResponse {
movieQuotaDays?: number;
tvQuotaLimit?: number;
tvQuotaDays?: number;
+ bookQuotaLimit?: number;
+ bookQuotaDays?: number;
globalMovieQuotaDays?: number;
globalMovieQuotaLimit?: number;
globalTvQuotaLimit?: number;
globalTvQuotaDays?: number;
+ globalBookQuotaLimit?: number;
+ globalBookQuotaDays?: number;
watchlistSyncMovies?: boolean;
watchlistSyncTv?: boolean;
}
@@ -26,7 +29,7 @@ export interface UserSettingsNotificationsResponse {
pgpKey?: string;
discordEnabled?: boolean;
discordEnabledTypes?: number;
- discordId?: string;
+ discordIds?: string[];
pushbulletAccessToken?: string;
pushoverApplicationToken?: string;
pushoverUserKey?: string;
diff --git a/server/job/blocklistedTagsProcessor.ts b/server/job/blocklistedTagsProcessor.ts
index 038b242b52..d4cbf9e881 100644
--- a/server/job/blocklistedTagsProcessor.ts
+++ b/server/job/blocklistedTagsProcessor.ts
@@ -1,5 +1,7 @@
-import type { SortOptions } from '@server/api/themoviedb';
-import { SortOptionsIterable } from '@server/api/themoviedb';
+import {
+ MovieSortOptionsIterable,
+ TvSortOptionsIterable,
+} from '@server/api/themoviedb';
import type {
TmdbSearchMovieResponse,
TmdbSearchTvResponse,
@@ -80,68 +82,40 @@ class BlocklistedTagProcessor implements RunnableScanner {
// The maximum number of queries we're expected to execute
this.total =
- 2 * blocklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
+ blocklistedTagsArr.length *
+ pageLimit *
+ (MovieSortOptionsIterable.length + TvSortOptionsIterable.length);
- for (const type of [MediaType.MOVIE, MediaType.TV]) {
- const getDiscover =
- type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
+ for (const tag of blocklistedTagsArr) {
+ const keywordDetails = await tmdb.getKeywordDetails({
+ keywordId: Number(tag),
+ });
- // Iterate for each tag
- for (const tag of blocklistedTagsArr) {
- const keywordDetails = await tmdb.getKeywordDetails({
- keywordId: Number(tag),
+ if (keywordDetails === null) {
+ logger.warn('Skipping invalid keyword in blocklisted tags', {
+ label: 'Blocklisted Tags Processor',
+ keywordId: tag,
});
-
- if (keywordDetails === null) {
- logger.warn('Skipping invalid keyword in blocklisted tags', {
- label: 'Blocklisted Tags Processor',
- keywordId: tag,
- });
- invalidKeywords.add(tag);
- continue;
- }
-
- let queryMax = pageLimit * SortOptionsIterable.length;
- let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
-
- for (let query = 0; query < queryMax; query++) {
- const page: number = fixedSortMode
- ? query + 1
- : (query % pageLimit) + 1;
- const sortBy: SortOptions | undefined = fixedSortMode
- ? undefined
- : SortOptionsIterable[query % SortOptionsIterable.length];
-
- if (!this.running) {
- throw new AbortTransaction();
- }
-
- try {
- const response = await getDiscover({
- page,
- sortBy,
- keywords: tag,
- });
-
- await this.processResults(response, tag, type, em);
- await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
-
- this.progress++;
- if (page === 1 && response.total_pages <= queryMax) {
- // We will finish the tag with less queries than expected, move progress accordingly
- this.progress += queryMax - response.total_pages;
- fixedSortMode = true;
- queryMax = response.total_pages;
- }
- } catch (error) {
- logger.error('Error processing keyword in blocklisted tags', {
- label: 'Blocklisted Tags Processor',
- keywordId: tag,
- errorMessage: error.message,
- });
- }
- }
+ invalidKeywords.add(tag);
+ continue;
}
+
+ await this.processTagForType(
+ MediaType.MOVIE,
+ tag,
+ em,
+ tmdb.getDiscoverMovies,
+ MovieSortOptionsIterable,
+ pageLimit
+ );
+ await this.processTagForType(
+ MediaType.TV,
+ tag,
+ em,
+ tmdb.getDiscoverTv,
+ TvSortOptionsIterable,
+ pageLimit
+ );
}
if (invalidKeywords.size > 0) {
@@ -163,6 +137,57 @@ class BlocklistedTagProcessor implements RunnableScanner {
}
}
+ private async processTagForType(
+ type: MediaType,
+ tag: string,
+ em: EntityManager,
+ getDiscover: (options: {
+ page: number;
+ sortBy?: S;
+ keywords: string;
+ }) => Promise,
+ sortOptions: readonly S[],
+ pageLimit: number
+ ): Promise {
+ let queryMax = pageLimit * sortOptions.length;
+ let fixedSortMode = false;
+
+ for (let query = 0; query < queryMax; query++) {
+ const page: number = fixedSortMode
+ ? query + 1
+ : Math.floor(query / sortOptions.length) + 1;
+ const sortBy: S | undefined = fixedSortMode
+ ? undefined
+ : sortOptions[query % sortOptions.length];
+
+ if (!this.running) {
+ throw new AbortTransaction();
+ }
+
+ try {
+ const response = await getDiscover({ page, sortBy, keywords: tag });
+
+ await this.processResults(response, tag, type, em);
+ await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
+
+ this.progress++;
+ if (page === 1 && response.total_pages <= queryMax) {
+ this.progress += queryMax - response.total_pages;
+ fixedSortMode = true;
+ queryMax = response.total_pages;
+ // restart the counter so sequential paging resumes at page 2 even if earlier queries failed
+ query = 0;
+ }
+ } catch (error) {
+ logger.error('Error processing keyword in blocklisted tags', {
+ label: 'Blocklisted Tags Processor',
+ keywordId: tag,
+ errorMessage: error.message,
+ });
+ }
+ }
+ }
+
private async processResults(
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
keywordId: string,
diff --git a/server/job/schedule.ts b/server/job/schedule.ts
index a9afd2f4d6..d28831e766 100644
--- a/server/job/schedule.ts
+++ b/server/job/schedule.ts
@@ -10,6 +10,10 @@ import {
} from '@server/lib/scanners/jellyfin';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
+import {
+ readarrRecentScanner,
+ readarrScanner,
+} from '@server/lib/scanners/readarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
@@ -177,7 +181,42 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
- // Checks if media is still available in plex/sonarr/radarr libs
+ // Run readarr recently added scan every 10 minutes
+ scheduledJobs.push({
+ id: 'readarr-recently-added-scan',
+ name: 'Readarr Recently Added Scan',
+ type: 'process',
+ interval: 'minutes',
+ cronSchedule: jobs['readarr-recently-added-scan'].schedule,
+ job: schedule.scheduleJob(
+ jobs['readarr-recently-added-scan'].schedule,
+ () => {
+ logger.info('Starting scheduled job: Readarr Recently Added Scan', {
+ label: 'Jobs',
+ });
+ readarrRecentScanner.run();
+ }
+ ),
+ running: () => readarrRecentScanner.status().running,
+ cancelFn: () => readarrRecentScanner.cancel(),
+ });
+
+ // Run full readarr scan every 24 hours
+ scheduledJobs.push({
+ id: 'readarr-scan',
+ name: 'Readarr Scan',
+ type: 'process',
+ interval: 'hours',
+ cronSchedule: jobs['readarr-scan'].schedule,
+ job: schedule.scheduleJob(jobs['readarr-scan'].schedule, () => {
+ logger.info('Starting scheduled job: Readarr Scan', { label: 'Jobs' });
+ readarrScanner.run();
+ }),
+ running: () => readarrScanner.status().running,
+ cancelFn: () => readarrScanner.cancel(),
+ });
+
+ // Checks if media is still available in plex/sonarr/radarr/readarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
diff --git a/server/lib/availabilitySync.test.ts b/server/lib/availabilitySync.test.ts
index cbfa74d809..7e861e38c5 100644
--- a/server/lib/availabilitySync.test.ts
+++ b/server/lib/availabilitySync.test.ts
@@ -8,15 +8,28 @@ import type {
import JellyfinAPI from '@server/api/jellyfin';
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
+import type { RadarrMovie } from '@server/api/servarr/radarr';
+import RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
-import { MediaStatus, MediaType } from '@server/constants/media';
+import TheMovieDb from '@server/api/themoviedb';
+import type {
+ TmdbTvDetails,
+ TmdbTvSeasonResult,
+} from '@server/api/themoviedb/interfaces';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
+import MediaRequest from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User';
-import type { SonarrSettings } from '@server/lib/settings';
+import availabilitySync from '@server/lib/availabilitySync';
+import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { setupTestDb } from '@server/test/db';
@@ -117,7 +130,105 @@ Object.defineProperty(SonarrAPI.prototype, 'getSeriesById', {
configurable: true,
});
-import availabilitySync from '@server/lib/availabilitySync';
+// --- Mock RadarrAPI ---
+let getMovieImpl: (id: number) => Promise = async () => {
+ throw new Error('404');
+};
+
+Object.defineProperty(RadarrAPI.prototype, 'getMovie', {
+ get() {
+ return async ({ id }: { id: number }) => getMovieImpl(id);
+ },
+ set() {},
+ configurable: true,
+});
+
+// --- Mock TheMovieDb ---
+let getTvShowImpl: (args: {
+ tvId: number;
+ language?: string;
+}) => Promise = async () => fakeTmdbShow(1);
+let getShowByTvdbIdImpl: (args: {
+ tvdbId: number;
+ language?: string;
+}) => Promise = async () => fakeTmdbShow(1);
+
+Object.defineProperty(TheMovieDb.prototype, 'getTvShowForScan', {
+ get() {
+ return async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args);
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(TheMovieDb.prototype, 'getShowByTvdbIdForScan', {
+ get() {
+ return async (args: { tvdbId: number; language?: string }) =>
+ getShowByTvdbIdImpl(args);
+ },
+ set() {},
+ configurable: true,
+});
+
+// getTvShowForScan is assigned in the constructor, so the prototype stub misses
+// the instance availabilitySync built when it was first imported
+Object.defineProperty(availabilitySync.tmdb, 'getTvShowForScan', {
+ value: async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args),
+ configurable: true,
+});
+
+// --- Helpers ---
+
+function fakeTmdbShow(
+ tmdbId: number,
+ seasons: TmdbTvSeasonResult[] = [
+ {
+ id: 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 1',
+ overview: '',
+ season_number: 1,
+ },
+ ]
+): TmdbTvDetails {
+ return {
+ id: tmdbId,
+ content_ratings: { results: [] },
+ created_by: [],
+ episode_run_time: [],
+ first_air_date: '2024-01-01',
+ genres: [],
+ homepage: '',
+ in_production: false,
+ languages: ['en'],
+ last_air_date: '2024-01-01',
+ name: 'Test Show',
+ networks: [],
+ number_of_episodes: 10,
+ number_of_seasons: seasons.length,
+ origin_country: ['US'],
+ original_language: 'en',
+ original_name: 'Test Show',
+ overview: '',
+ popularity: 0,
+ production_companies: [],
+ production_countries: [],
+ spoken_languages: [],
+ seasons,
+ status: 'Ended',
+ type: 'Scripted',
+ vote_average: 0,
+ vote_count: 0,
+ aggregate_credits: { cast: [] },
+ credits: { crew: [] },
+ external_ids: {},
+ keywords: { results: [] },
+ videos: { results: [] },
+ };
+}
setupTestDb();
@@ -150,6 +261,33 @@ function configureSonarr(overrides: Partial[] = [{}]): void {
settings.radarr = [];
}
+function configureRadarr(overrides: Partial[] = [{}]): void {
+ const settings = getSettings();
+ settings.radarr = overrides.map((o, i) => ({
+ id: i,
+ name: `Radarr ${i}`,
+ hostname: 'localhost',
+ port: 7878,
+ apiKey: 'test-key',
+ baseUrl: '',
+ useSsl: false,
+ activeProfileId: 1,
+ activeProfileName: 'Default',
+ activeDirectory: '/movies',
+ minimumAvailability: 'released',
+ tags: [],
+ is4k: false,
+ isDefault: i === 0,
+ syncEnabled: true,
+ preventSearch: false,
+ tagRequests: false,
+ overrideRule: [],
+ externalUrl: '',
+ ...o,
+ })) as RadarrSettings[];
+ settings.sonarr = [];
+}
+
function configureJellyfin(): void {
const settings = getSettings();
settings.main.mediaServerType = MediaServerType.JELLYFIN;
@@ -282,8 +420,8 @@ function fakeSonarrSeasons(
monitored: true,
statistics: {
episodeFileCount: seasonsWithFiles[i + 1] ?? 0,
- totalEpisodeCount: 22,
- episodeCount: 22,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
percentOfEpisodes: seasonsWithFiles[i + 1] ? 100 : 0,
sizeOnDisk: seasonsWithFiles[i + 1] ? 7516192768 : 0,
previousAiring: undefined,
@@ -304,6 +442,33 @@ describe('AvailabilitySync', () => {
getSeriesByIdImpl = async () => {
throw new Error('404');
};
+ getMovieImpl = async () => {
+ throw new Error('404');
+ };
+ getTvShowImpl = async ({ tvId }) =>
+ fakeTmdbShow(
+ tvId,
+ Array.from({ length: 4 }, (_, i) => ({
+ id: i + 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: `Season ${i + 1}`,
+ overview: '',
+ season_number: i + 1,
+ }))
+ );
+ getShowByTvdbIdImpl = async ({ tvdbId }) =>
+ fakeTmdbShow(
+ tvdbId,
+ Array.from({ length: 4 }, (_, i) => ({
+ id: i + 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: `Season ${i + 1}`,
+ overview: '',
+ season_number: i + 1,
+ }))
+ );
const userRepository = getRepository(User);
const existingAdmin = await userRepository.findOne({ where: { id: 1 } });
@@ -363,7 +528,7 @@ describe('AvailabilitySync', () => {
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
if (seasonID === 'jellyfin-season-6-id') {
- return fakeJellyfinEpisodes(21);
+ return fakeJellyfinEpisodes(10);
}
return [];
};
@@ -378,13 +543,13 @@ describe('AvailabilitySync', () => {
monitored: true,
statistics: {
episodeFileCount: 21,
- totalEpisodeCount: 177,
- episodeCount: 177,
- percentOfEpisodes: 11.86,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
+ percentOfEpisodes: 100,
sizeOnDisk: 0,
seasonCount: 8,
},
- seasons: fakeSonarrSeasons(8, { 6: 21 }),
+ seasons: fakeSonarrSeasons(8, { 6: 10 }),
} as unknown as SonarrSeries;
}
throw new Error('404');
@@ -688,6 +853,106 @@ describe('AvailabilitySync', () => {
'Show should remain AVAILABLE when getEpisodes fails'
);
});
+
+ it('should mark show as PARTIALLY_AVAILABLE when some seasons are available and some are unknown', async () => {
+ configureJellyfin();
+ configureSonarr([{ syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 1412;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.jellyfinMediaId = 'jellyfin-partial-id';
+ media.externalServiceId = 103;
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 3,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 4,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getItemDataImpl = async (id: string) => {
+ if (id === 'jellyfin-partial-id') {
+ return fakeJellyfinShow('jellyfin-partial-id', '1412');
+ }
+ return undefined;
+ };
+
+ getSeasonsImpl = async (seriesID: string) => {
+ if (seriesID === 'jellyfin-partial-id') {
+ return [
+ fakeJellyfinSeason(1, 'jellyfin-partial-s1-id'),
+ fakeJellyfinSeason(2, 'jellyfin-partial-s2-id'),
+ ];
+ }
+ return [];
+ };
+
+ getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
+ if (seasonID === 'jellyfin-partial-s1-id') {
+ return fakeJellyfinEpisodes(10);
+ }
+ if (seasonID === 'jellyfin-partial-s2-id') {
+ return fakeJellyfinEpisodes(10);
+ }
+ return [];
+ };
+
+ getSeriesByIdImpl = async (id: number) => {
+ if (id === 103) {
+ return {
+ tvdbId: 99997,
+ id: 103,
+ title: 'Partial Show',
+ titleSlug: 'partial-show',
+ monitored: true,
+ statistics: {
+ episodeFileCount: 20,
+ totalEpisodeCount: 40,
+ episodeCount: 40,
+ percentOfEpisodes: 50,
+ sizeOnDisk: 0,
+ seasonCount: 4,
+ },
+ seasons: fakeSonarrSeasons(4, { 1: 10, 2: 10 }),
+ } as unknown as SonarrSeries;
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1412 },
+ relations: ['seasons'],
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.PARTIALLY_AVAILABLE,
+ 'Show should be PARTIALLY_AVAILABLE when some seasons are available and some are unknown'
+ );
+ });
});
describe('TV season availability - Plex', () => {
@@ -792,6 +1057,86 @@ describe('AvailabilitySync', () => {
);
});
+ it('should mark a deleted show as DELETED when a second standard Sonarr instance has a colliding externalServiceId', async () => {
+ configurePlex();
+ configureSonarr([{ syncEnabled: true }, { syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 3000;
+ media.tvdbId = 73255;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.ratingKey = 'gone-from-plex-rk';
+ media.externalServiceId = 200;
+ media.serviceId = 0;
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ // Probed once per standard instance with the same id (200): origin 404s
+ // (deleted); the other instance has a different series at 200.
+ let sonarrCall = 0;
+ getSeriesByIdImpl = async (id: number) => {
+ if (id !== 200) {
+ throw new Error('404');
+ }
+ sonarrCall += 1;
+ if (sonarrCall === 1) {
+ throw new Error('404');
+ }
+ return {
+ tvdbId: 999999,
+ id: 200,
+ title: 'Unrelated Colliding Series',
+ titleSlug: 'unrelated-colliding-series',
+ monitored: true,
+ statistics: {
+ episodeFileCount: 12,
+ totalEpisodeCount: 12,
+ episodeCount: 12,
+ percentOfEpisodes: 100,
+ sizeOnDisk: 0,
+ seasonCount: 1,
+ },
+ seasons: fakeSonarrSeasons(1, { 1: 12 }),
+ } as unknown as SonarrSeries;
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 3000 },
+ relations: ['seasons'],
+ });
+
+ for (const season of updated.seasons) {
+ assert.strictEqual(
+ season.status,
+ MediaStatus.DELETED,
+ `Season ${season.seasonNumber} should be DELETED (collision must not keep it available) but was ${season.status}`
+ );
+ }
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.DELETED,
+ 'Show deleted from its origin instance and Plex must not be kept alive by a colliding externalServiceId on another standard instance'
+ );
+ });
+
it('should assume season exists when getChildrenMetadata fails for episodes (safe fallback)', async () => {
configurePlex();
configureSonarr([{ syncEnabled: true }]);
@@ -959,4 +1304,1015 @@ describe('AvailabilitySync', () => {
);
});
});
+
+ describe('movie availability - Radarr', () => {
+ it('should mark a deleted movie as DELETED when a second standard Radarr instance has a colliding externalServiceId', async () => {
+ configurePlex();
+ configureRadarr([{ syncEnabled: true }, { syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 5000;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.AVAILABLE;
+ media.ratingKey = 'gone-from-plex-rk';
+ media.externalServiceId = 300;
+ media.serviceId = 0;
+
+ await mediaRepository.save(media);
+
+ // Probed once per standard instance with the same id (300): origin 404s
+ // (deleted); the other instance has a different movie at 300.
+ let radarrCall = 0;
+ getMovieImpl = async (id: number) => {
+ if (id !== 300) {
+ throw new Error('404');
+ }
+ radarrCall += 1;
+ if (radarrCall === 1) {
+ throw new Error('404');
+ }
+ return {
+ id: 300,
+ tmdbId: 999999,
+ title: 'Unrelated Colliding Movie',
+ hasFile: true,
+ } as unknown as RadarrMovie;
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 5000 },
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.DELETED,
+ 'Movie deleted from its origin instance and Plex must not be kept alive by a colliding externalServiceId on another standard instance'
+ );
+ });
+ });
+
+ describe('movie availability - Plex 4K detection', () => {
+ function fakePlexMovie(ratingKey: string, widths: number[]): PlexMetadata {
+ return {
+ ratingKey,
+ guid: `plex://movie/${ratingKey}`,
+ type: 'movie',
+ title: 'Test Movie',
+ Guid: [],
+ index: 1,
+ leafCount: 0,
+ viewedLeafCount: 0,
+ addedAt: 0,
+ updatedAt: 0,
+ Media: widths.map((width, i) => ({
+ id: i,
+ duration: 7200,
+ bitrate: 20000,
+ width,
+ height: width >= 2000 ? 2160 : 1080,
+ aspectRatio: 1.78,
+ audioChannels: 6,
+ audioCodec: 'eac3',
+ videoCodec: width >= 2000 ? 'hevc' : 'h264',
+ videoResolution: width >= 2000 ? '4k' : '1080',
+ container: 'mkv',
+ videoFrameRate: '24p',
+ videoProfile: 'main 10',
+ })),
+ };
+ }
+
+ it('should keep the 4K status when both statuses share a Plex item that has a 4K version (merged versions)', async () => {
+ configurePlex();
+ configureRadarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ // The Plex scanner writes the same rating key to both versions of a
+ // merged item, and a prior deletion left no externalServiceId4k to
+ // query, so retention rides on the Plex check alone.
+ const media = new Media();
+ media.tmdbId = 687163;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.AVAILABLE;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey = 'merged-versions-rk';
+ media.ratingKey4k = 'merged-versions-rk';
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'merged-versions-rk') {
+ return fakePlexMovie('merged-versions-rk', [1920, 3840]);
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687163 },
+ });
+
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.AVAILABLE,
+ 'A 4K version present on the media server must prevent 4K removal even when both statuses share one rating key'
+ );
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'The non-4K status must be unaffected'
+ );
+ });
+
+ it('should still remove a stale 4K status when the shared Plex item has no 4K version', async () => {
+ configurePlex();
+ configureRadarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687164;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.AVAILABLE;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey = 'stale-4k-rk';
+ media.ratingKey4k = 'stale-4k-rk';
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'stale-4k-rk') {
+ return fakePlexMovie('stale-4k-rk', [1920]);
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687164 },
+ });
+
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.DELETED,
+ 'A phantom 4K status on a 1080p-only item must still be removed'
+ );
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'The non-4K status must be kept'
+ );
+ });
+
+ it('should keep a 4K-only movie referenced only by ratingKey4k', async () => {
+ configurePlex();
+ configureRadarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687165;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.UNKNOWN;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey4k = 'pure-4k-rk';
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'pure-4k-rk') {
+ return fakePlexMovie('pure-4k-rk', [3840]);
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687165 },
+ });
+
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.AVAILABLE,
+ 'A 4K-only item with a distinct rating key must be kept'
+ );
+ });
+
+ it('should not remove a 4K movie when Plex has not yet analyzed the file (empty Media array)', async () => {
+ configurePlex();
+ configureRadarr([{ is4k: true, syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687166;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.UNKNOWN;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey4k = 'analyzing-rk';
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'analyzing-rk') {
+ return fakePlexMovie('analyzing-rk', []);
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687166 },
+ });
+
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.AVAILABLE,
+ 'Metadata without analyzed versions proves nothing about the file and must not trigger removal'
+ );
+ });
+
+ it('should remove the non-4K status when the shared Plex item only has a 4K version left', async () => {
+ configurePlex();
+ configureRadarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ // Merged item whose non-4K file was removed from disk: the item still
+ // exists in Plex, but only with the 4K version.
+ const media = new Media();
+ media.tmdbId = 687170;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.AVAILABLE;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey = 'shared-4k-only-rk';
+ media.ratingKey4k = 'shared-4k-only-rk';
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'shared-4k-only-rk') {
+ return fakePlexMovie('shared-4k-only-rk', [3840]);
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687170 },
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.DELETED,
+ 'The non-4K status must be removed when the shared item no longer has a non-4K version'
+ );
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.AVAILABLE,
+ 'The 4K status must be kept'
+ );
+ });
+ });
+
+ describe('movie deletion metadata handling', () => {
+ it('should keep service metadata when deleting a 4K movie with an approved 4K request in flight', async () => {
+ configurePlex();
+ configureRadarr([{ is4k: true, syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687167;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.UNKNOWN;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey4k = 'req-in-flight-rk';
+ media.serviceId4k = 0;
+ media.externalServiceId4k = 512;
+ media.externalServiceSlug4k = 'test-movie';
+ await mediaRepository.save(media);
+
+ // Insert through a query builder with listeners disabled so
+ // MediaRequestSubscriber.afterInsert does not try to reach Radarr.
+ await getRepository(MediaRequest)
+ .createQueryBuilder()
+ .insert()
+ .into(MediaRequest)
+ .values({
+ status: MediaRequestStatus.APPROVED,
+ media: { id: media.id },
+ requestedBy: { id: 1 },
+ type: MediaType.MOVIE,
+ is4k: true,
+ })
+ .callListeners(false)
+ .execute();
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687167 },
+ });
+
+ assert.strictEqual(updated.status4k, MediaStatus.DELETED);
+ assert.strictEqual(
+ updated.externalServiceId4k,
+ 512,
+ 'externalServiceId4k must be kept while an approved 4K request is in flight'
+ );
+ assert.strictEqual(updated.serviceId4k, 0);
+ assert.strictEqual(updated.externalServiceSlug4k, 'test-movie');
+ assert.strictEqual(
+ updated.ratingKey4k,
+ 'req-in-flight-rk',
+ 'ratingKey4k must be kept while an approved 4K request is in flight'
+ );
+ });
+
+ it('should null service metadata when deleting a 4K movie with no open request', async () => {
+ configurePlex();
+ configureRadarr([{ is4k: true, syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687168;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.UNKNOWN;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey4k = 'no-request-rk';
+ media.serviceId4k = 0;
+ media.externalServiceId4k = 640;
+ media.externalServiceSlug4k = 'another-movie';
+ await mediaRepository.save(media);
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687168 },
+ });
+
+ assert.strictEqual(updated.status4k, MediaStatus.DELETED);
+ assert.strictEqual(updated.externalServiceId4k, null);
+ assert.strictEqual(updated.serviceId4k, null);
+ assert.strictEqual(updated.externalServiceSlug4k, null);
+ assert.strictEqual(updated.ratingKey4k, null);
+ });
+ });
+
+ describe('movie availability - scan-disabled servers', () => {
+ it('should count a configured but scan-disabled 4K Radarr server as existence evidence', async () => {
+ configurePlex();
+ configureRadarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: false, port: 7879 },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687169;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.UNKNOWN;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey4k = 'not-in-plex-rk';
+ media.serviceId4k = 1;
+ media.externalServiceId4k = 700;
+ await mediaRepository.save(media);
+
+ getMovieImpl = async (id: number) => {
+ if (id !== 700) {
+ throw new Error('404');
+ }
+ return {
+ id: 700,
+ tmdbId: 687169,
+ title: 'Test Movie',
+ hasFile: true,
+ } as unknown as RadarrMovie;
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687169 },
+ });
+
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.AVAILABLE,
+ 'A movie present on a configured 4K Radarr server must be kept even when that server has scanning disabled'
+ );
+ });
+ });
+
+ describe('show availability - merged Plex versions', () => {
+ function fakeVersionedEpisode(
+ ratingKey: string,
+ widths: number[]
+ ): PlexMetadata {
+ return {
+ ratingKey,
+ guid: `plex://episode/${ratingKey}`,
+ type: 'movie' as const,
+ title: ratingKey,
+ Guid: [],
+ index: 1,
+ leafCount: 0,
+ viewedLeafCount: 0,
+ addedAt: 0,
+ updatedAt: 0,
+ Media: widths.map((width, i) => ({
+ id: i,
+ duration: 2400,
+ bitrate: width >= 2000 ? 20000 : 4000,
+ width,
+ height: width >= 2000 ? 2160 : 1080,
+ aspectRatio: 1.78,
+ audioChannels: 6,
+ audioCodec: 'eac3',
+ videoCodec: width >= 2000 ? 'hevc' : 'h264',
+ videoResolution: width >= 2000 ? '4k' : '1080',
+ container: 'mkv',
+ videoFrameRate: '24p',
+ videoProfile: width >= 2000 ? 'main 10' : 'high',
+ })),
+ };
+ }
+
+ it('should remove a stale 4K season status when the shared item has no 4K episodes for that season', async () => {
+ configurePlex();
+ configureSonarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687171;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey = 'merged-show-rk';
+ media.ratingKey4k = 'merged-show-rk';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.AVAILABLE,
+ }),
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.AVAILABLE,
+ }),
+ ];
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'merged-show-rk') {
+ return fakePlexShow('merged-show-rk');
+ }
+ throw new Error('404');
+ };
+
+ // Season 1 has a 4K version, season 2 only has a 1080p file left.
+ getChildrenMetadataImpl = async (key: string) => {
+ if (key === 'merged-show-rk') {
+ return [fakePlexSeason(1, 'ms-s1-rk'), fakePlexSeason(2, 'ms-s2-rk')];
+ }
+ if (key === 'ms-s1-rk') {
+ return [fakeVersionedEpisode('ms-s1-e1', [1920, 3840])];
+ }
+ if (key === 'ms-s2-rk') {
+ return [fakeVersionedEpisode('ms-s2-e1', [1920])];
+ }
+ return [];
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687171 },
+ relations: ['seasons'],
+ });
+ const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
+ const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
+
+ assert.strictEqual(
+ s2?.status4k,
+ MediaStatus.DELETED,
+ 'A 4K season status without any 4K episode on the shared item must be removed'
+ );
+ assert.strictEqual(
+ s1?.status4k,
+ MediaStatus.AVAILABLE,
+ 'A season with a 4K episode must be kept'
+ );
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.PARTIALLY_AVAILABLE,
+ 'The 4K show must roll up to partially available'
+ );
+ assert.strictEqual(s1?.status, MediaStatus.AVAILABLE);
+ assert.strictEqual(s2?.status, MediaStatus.AVAILABLE);
+ assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
+ });
+
+ it('should remove a stale non-4K season status when the shared item only has 4K episodes for that season', async () => {
+ configurePlex();
+ configureSonarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687172;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey = 'merged-show2-rk';
+ media.ratingKey4k = 'merged-show2-rk';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.AVAILABLE,
+ }),
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.AVAILABLE,
+ }),
+ ];
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'merged-show2-rk') {
+ return fakePlexShow('merged-show2-rk');
+ }
+ throw new Error('404');
+ };
+
+ // Season 2's non-4K file was removed; only the 4K version remains.
+ getChildrenMetadataImpl = async (key: string) => {
+ if (key === 'merged-show2-rk') {
+ return [
+ fakePlexSeason(1, 'ms2-s1-rk'),
+ fakePlexSeason(2, 'ms2-s2-rk'),
+ ];
+ }
+ if (key === 'ms2-s1-rk') {
+ return [fakeVersionedEpisode('ms2-s1-e1', [1920, 3840])];
+ }
+ if (key === 'ms2-s2-rk') {
+ return [fakeVersionedEpisode('ms2-s2-e1', [3840])];
+ }
+ return [];
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687172 },
+ relations: ['seasons'],
+ });
+ const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
+ const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
+
+ assert.strictEqual(
+ s2?.status,
+ MediaStatus.DELETED,
+ 'A non-4K season status without any non-4K episode on the shared item must be removed'
+ );
+ assert.strictEqual(
+ s2?.status4k,
+ MediaStatus.AVAILABLE,
+ 'The 4K season status must be kept'
+ );
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.PARTIALLY_AVAILABLE,
+ 'The non-4K show must roll up to partially available'
+ );
+ assert.strictEqual(s1?.status, MediaStatus.AVAILABLE);
+ assert.strictEqual(s1?.status4k, MediaStatus.AVAILABLE);
+ assert.strictEqual(updated.status4k, MediaStatus.AVAILABLE);
+ });
+
+ it('should keep a non-4K season with only 4K episodes when no 4K Sonarr server is configured', async () => {
+ // Single-server setups track 4K files under the plain status;
+ // without a 4K Sonarr the resolution check must stay disabled.
+ configurePlex();
+ configureSonarr([{ syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687173;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.ratingKey = 'single-4k-show-rk';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'single-4k-show-rk') {
+ return fakePlexShow('single-4k-show-rk');
+ }
+ throw new Error('404');
+ };
+
+ getChildrenMetadataImpl = async (key: string) => {
+ if (key === 'single-4k-show-rk') {
+ return [fakePlexSeason(1, 's4k-s1-rk')];
+ }
+ if (key === 's4k-s1-rk') {
+ return [fakeVersionedEpisode('s4k-s1-e1', [3840])];
+ }
+ return [];
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687173 },
+ relations: ['seasons'],
+ });
+ const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
+
+ assert.strictEqual(
+ s1?.status,
+ MediaStatus.AVAILABLE,
+ 'Without a 4K Sonarr server a 4K-only season must keep its non-4K status'
+ );
+ assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
+ });
+
+ it('should not remove the 4K show status when episode metadata cannot be fetched for any season', async () => {
+ configurePlex();
+ configureSonarr([
+ { syncEnabled: true },
+ { is4k: true, syncEnabled: true },
+ ]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 687174;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.status4k = MediaStatus.AVAILABLE;
+ media.ratingKey = 'merged-show3-rk';
+ media.ratingKey4k = 'merged-show3-rk';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.AVAILABLE,
+ }),
+ ];
+ await mediaRepository.save(media);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'merged-show3-rk') {
+ return fakePlexShow('merged-show3-rk');
+ }
+ throw new Error('404');
+ };
+
+ getChildrenMetadataImpl = async (key: string) => {
+ if (key === 'merged-show3-rk') {
+ return [fakePlexSeason(1, 'ms3-s1-rk')];
+ }
+ throw new Error('episode fetch failed');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 687174 },
+ relations: ['seasons'],
+ });
+ const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
+
+ assert.strictEqual(
+ updated.status4k,
+ MediaStatus.AVAILABLE,
+ 'Unverifiable episode metadata proves nothing about the files and must not trigger 4K removal'
+ );
+ assert.strictEqual(s1?.status4k, MediaStatus.AVAILABLE);
+ assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
+ assert.strictEqual(s1?.status, MediaStatus.AVAILABLE);
+ });
+ });
+
+ describe('specials season handling', () => {
+ const tmdbSeasonsWithSpecials = [
+ {
+ id: 100,
+ air_date: '2024-01-01',
+ episode_count: 3,
+ name: 'Specials',
+ overview: '',
+ season_number: 0,
+ },
+ {
+ id: 101,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 1',
+ overview: '',
+ season_number: 1,
+ },
+ ];
+
+ it('should not demote an available show when only the specials season is missing (Jellyfin)', async () => {
+ configureJellyfin();
+ configureSonarr([{ syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 13862;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.jellyfinMediaId = 'jellyfin-shogun-id';
+ media.externalServiceId = 300;
+ media.seasons = [
+ new Season({
+ seasonNumber: 0,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getTvShowImpl = async () => fakeTmdbShow(13862, tmdbSeasonsWithSpecials);
+
+ getItemDataImpl = async (id: string) => {
+ if (id === 'jellyfin-shogun-id') {
+ return fakeJellyfinShow('jellyfin-shogun-id', '13862');
+ }
+ return undefined;
+ };
+
+ getSeasonsImpl = async (seriesID: string) => {
+ if (seriesID === 'jellyfin-shogun-id') {
+ return [fakeJellyfinSeason(1, 'jellyfin-shogun-s1-id')];
+ }
+ return [];
+ };
+
+ getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
+ if (seasonID === 'jellyfin-shogun-s1-id') {
+ return fakeJellyfinEpisodes(10);
+ }
+ return [];
+ };
+
+ getSeriesByIdImpl = async (id: number) => {
+ if (id === 300) {
+ return {
+ tvdbId: 70814,
+ id: 300,
+ title: 'Shogun',
+ titleSlug: 'shogun',
+ monitored: true,
+ statistics: {
+ episodeFileCount: 10,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
+ percentOfEpisodes: 100,
+ sizeOnDisk: 0,
+ seasonCount: 1,
+ },
+ seasons: fakeSonarrSeasons(1, { 1: 10 }),
+ } as unknown as SonarrSeries;
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 13862 },
+ relations: ['seasons'],
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'Show should stay AVAILABLE when only the specials season is missing'
+ );
+ });
+
+ it('should not demote an available show when only the specials season is missing (Plex)', async () => {
+ configurePlex();
+ configureSonarr([{ syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 13863;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.ratingKey = 'plex-shogun-rk';
+ media.externalServiceId = 301;
+ media.seasons = [
+ new Season({
+ seasonNumber: 0,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getTvShowImpl = async () => fakeTmdbShow(13863, tmdbSeasonsWithSpecials);
+
+ getMetadataImpl = async (key: string) => {
+ if (key === 'plex-shogun-rk') {
+ return fakePlexShow('plex-shogun-rk');
+ }
+ throw new Error('404');
+ };
+
+ getChildrenMetadataImpl = async (key: string) => {
+ if (key === 'plex-shogun-rk') {
+ return [fakePlexSeason(1, 'plex-shogun-s1-rk')];
+ }
+ if (key === 'plex-shogun-s1-rk') {
+ return fakePlexEpisodes(10);
+ }
+ return [];
+ };
+
+ getSeriesByIdImpl = async (id: number) => {
+ if (id === 301) {
+ return {
+ tvdbId: 70814,
+ id: 301,
+ title: 'Shogun',
+ titleSlug: 'shogun',
+ monitored: true,
+ statistics: {
+ episodeFileCount: 10,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
+ percentOfEpisodes: 100,
+ sizeOnDisk: 0,
+ seasonCount: 1,
+ },
+ seasons: fakeSonarrSeasons(1, { 1: 10 }),
+ } as unknown as SonarrSeries;
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 13863 },
+ relations: ['seasons'],
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'Show should stay AVAILABLE when only the specials season is missing'
+ );
+ });
+
+ it('should mark a removed specials season as DELETED without demoting the show (Jellyfin)', async () => {
+ configureJellyfin();
+ configureSonarr([{ syncEnabled: true }]);
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 13864;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.AVAILABLE;
+ media.jellyfinMediaId = 'jellyfin-specials-id';
+ media.externalServiceId = 302;
+ media.seasons = [
+ new Season({
+ seasonNumber: 0,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getTvShowImpl = async () => fakeTmdbShow(13864, tmdbSeasonsWithSpecials);
+
+ getItemDataImpl = async (id: string) => {
+ if (id === 'jellyfin-specials-id') {
+ return fakeJellyfinShow('jellyfin-specials-id', '13864');
+ }
+ return undefined;
+ };
+
+ getSeasonsImpl = async (seriesID: string) => {
+ if (seriesID === 'jellyfin-specials-id') {
+ return [fakeJellyfinSeason(1, 'jellyfin-specials-s1-id')];
+ }
+ return [];
+ };
+
+ getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
+ if (seasonID === 'jellyfin-specials-s1-id') {
+ return fakeJellyfinEpisodes(10);
+ }
+ return [];
+ };
+
+ getSeriesByIdImpl = async (id: number) => {
+ if (id === 302) {
+ return {
+ tvdbId: 70814,
+ id: 302,
+ title: 'Shogun',
+ titleSlug: 'shogun',
+ monitored: true,
+ statistics: {
+ episodeFileCount: 10,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
+ percentOfEpisodes: 100,
+ sizeOnDisk: 0,
+ seasonCount: 1,
+ },
+ seasons: fakeSonarrSeasons(1, { 1: 10 }),
+ } as unknown as SonarrSeries;
+ }
+ throw new Error('404');
+ };
+
+ await availabilitySync.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 13864 },
+ relations: ['seasons'],
+ });
+
+ const specials = updated.seasons.find((s) => s.seasonNumber === 0);
+ assert.strictEqual(
+ specials?.status,
+ MediaStatus.DELETED,
+ 'Removed specials season should be marked DELETED'
+ );
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'Show should stay AVAILABLE when only specials were removed'
+ );
+ });
+ });
});
diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts
index cdbe80b54d..4a700ef355 100644
--- a/server/lib/availabilitySync.ts
+++ b/server/lib/availabilitySync.ts
@@ -5,6 +5,11 @@ import PlexAPI from '@server/api/plexapi';
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
+import TheMovieDb from '@server/api/themoviedb';
+import type {
+ TmdbTvDetails,
+ TmdbTvScanDetails,
+} from '@server/api/themoviedb/interfaces';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
@@ -30,6 +35,10 @@ class AvailabilitySync {
private sonarrSeasonsCache: Record;
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
+ private enable4kMovie: boolean;
+ private enable4kShow: boolean;
+
+ readonly tmdb = new TheMovieDb();
async run() {
const settings = getSettings();
@@ -40,12 +49,14 @@ class AvailabilitySync {
this.jellyfinSeasonsCache = {};
this.jellyfinEpisodeExistsCache = {};
this.sonarrSeasonsCache = {};
- this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
- this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
+ this.radarrServers = settings.radarr;
+ this.sonarrServers = settings.sonarr;
+ this.enable4kMovie = this.radarrServers.some((server) => server.is4k);
+ this.enable4kShow = this.sonarrServers.some((server) => server.is4k);
try {
logger.info(`Starting availability sync...`, {
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
});
const pageSize = 50;
@@ -149,7 +160,7 @@ class AvailabilitySync {
if (existsInPlex || existsInRadarr) {
movieExists = true;
- logger.info(
+ logger.debug(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -159,7 +170,7 @@ class AvailabilitySync {
if (existsInPlex4k || existsInRadarr4k) {
movieExists4k = true;
- logger.info(
+ logger.debug(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -182,7 +193,7 @@ class AvailabilitySync {
if (existsInJellyfin || existsInRadarr) {
movieExists = true;
- logger.info(
+ logger.debug(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -192,7 +203,7 @@ class AvailabilitySync {
if (existsInJellyfin4k || existsInRadarr4k) {
movieExists4k = true;
- logger.info(
+ logger.debug(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -246,7 +257,7 @@ class AvailabilitySync {
if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex || existsInSonarr) {
showExists = true;
- logger.info(
+ logger.debug(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -258,7 +269,7 @@ class AvailabilitySync {
if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex4k || existsInSonarr4k) {
showExists4k = true;
- logger.info(
+ logger.debug(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -274,7 +285,7 @@ class AvailabilitySync {
) {
if (existsInJellyfin || existsInSonarr) {
showExists = true;
- logger.info(
+ logger.debug(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -289,7 +300,7 @@ class AvailabilitySync {
) {
if (existsInJellyfin4k || existsInSonarr4k) {
showExists4k = true;
- logger.info(
+ logger.debug(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
@@ -353,6 +364,52 @@ class AvailabilitySync {
]);
}
+ // We need to fetch from TMDB to get the episode count for each season
+ let tvShow: TmdbTvScanDetails | TmdbTvDetails | undefined;
+ try {
+ if (media.tmdbId) {
+ tvShow = await this.tmdb.getTvShowForScan({
+ tvId: Number(media.tmdbId),
+ });
+ } else if (media.tvdbId) {
+ tvShow = await this.tmdb.getShowByTvdbIdForScan({
+ tvdbId: Number(media.tvdbId),
+ });
+ }
+ } catch (e) {
+ logger.debug(
+ `Failed to fetch TMDB data for show [TMDB ID ${media.tmdbId}]. Skipping season enrichment.`,
+ { label: 'AvailabilitySync', errorMessage: e.message }
+ );
+ }
+
+ if (tvShow) {
+ // fill the finalSeasons and finalSeasons4k maps with false for missing seasons
+ media.seasons.forEach((season) => {
+ // Specials don't count towards availability (baseScanner skips them too)
+ // TODO: doesn't respect enableSpecialEpisodes; needs a shared predicate with baseScanner.ts
+ if (season.seasonNumber === 0) {
+ return;
+ }
+ if (
+ !finalSeasons.has(season.seasonNumber) &&
+ tvShow.seasons.find(
+ (s) => s.season_number === season.seasonNumber
+ )?.episode_count
+ ) {
+ finalSeasons.set(season.seasonNumber, false);
+ }
+ if (
+ !finalSeasons4k.has(season.seasonNumber) &&
+ tvShow.seasons.find(
+ (s) => s.season_number === season.seasonNumber
+ )?.episode_count
+ ) {
+ finalSeasons4k.set(season.seasonNumber, false);
+ }
+ });
+ }
+
if (
!showExists &&
(media.status === MediaStatus.AVAILABLE ||
@@ -405,11 +462,11 @@ class AvailabilitySync {
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
});
this.running = false;
}
@@ -453,31 +510,29 @@ class AvailabilitySync {
const mediaRepository = getRepository(Media);
try {
- // If media type is tv, check if a season is processing
+ // Check if an approved request for this version is still in flight
// to see if we need to keep the external metadata
let isMediaProcessing = false;
- if (media.mediaType === 'tv') {
- const requestRepository = getRepository(MediaRequest);
-
- const request = await requestRepository
- .createQueryBuilder('request')
- .leftJoinAndSelect('request.media', 'media')
- .where('(media.id = :id)', {
- id: media.id,
- })
- .andWhere(
- '(request.is4k = :is4k AND request.status = :requestStatus)',
- {
- requestStatus: MediaRequestStatus.APPROVED,
- is4k: is4k,
- }
- )
- .getOne();
+ const requestRepository = getRepository(MediaRequest);
- if (request) {
- isMediaProcessing = true;
- }
+ const request = await requestRepository
+ .createQueryBuilder('request')
+ .leftJoinAndSelect('request.media', 'media')
+ .where('(media.id = :id)', {
+ id: media.id,
+ })
+ .andWhere(
+ '(request.is4k = :is4k AND request.status = :requestStatus)',
+ {
+ requestStatus: MediaRequestStatus.APPROVED,
+ is4k: is4k,
+ }
+ )
+ .getOne();
+
+ if (request) {
+ isMediaProcessing = true;
}
// Set the non-4K or 4K media to deleted
@@ -508,7 +563,7 @@ class AvailabilitySync {
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
: null;
}
- logger.info(
+ logger.debug(
`The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show'
} [TMDB ID ${media.tmdbId}] was not found in any ${
@@ -531,7 +586,7 @@ class AvailabilitySync {
} [TMDB ID ${media.tmdbId}].`,
{
errorMessage: ex.message,
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
}
);
}
@@ -554,49 +609,45 @@ class AvailabilitySync {
);
// Retrieve the season keys to pass into our log
const seasonKeys = [...seasonsPendingRemoval.keys()];
-
- // let isSeasonRemoved = false;
+ // Specials can still be marked DELETED below, but shouldn't demote the show
+ const nonSpecialSeasonKeys = seasonKeys.filter((key) => key !== 0);
try {
for (const mediaSeason of media.seasons) {
- if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
+ if (
+ seasonsPendingRemoval.has(mediaSeason.seasonNumber) &&
+ (mediaSeason[is4k ? 'status4k' : 'status'] ===
+ MediaStatus.AVAILABLE ||
+ mediaSeason[is4k ? 'status4k' : 'status'] ===
+ MediaStatus.PARTIALLY_AVAILABLE)
+ ) {
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
}
}
- if (media.status === MediaStatus.AVAILABLE && !is4k) {
- media.status = MediaStatus.PARTIALLY_AVAILABLE;
- logger.info(
- `Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
- { label: 'Availability Sync' }
- );
- }
-
- if (media.status4k === MediaStatus.AVAILABLE && is4k) {
- media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
- logger.info(
- `Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
- { label: 'Availability Sync' }
+ if (
+ nonSpecialSeasonKeys.length > 0 &&
+ media[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
+ ) {
+ media[is4k ? 'status4k' : 'status'] = MediaStatus.PARTIALLY_AVAILABLE;
+ logger.debug(
+ `Marking the ${
+ is4k ? '4K' : 'non-4K'
+ } show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season(s) [${nonSpecialSeasonKeys}] was not found in any ${
+ media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
+ } and ${
+ mediaServerType === MediaServerType.PLEX
+ ? 'plex'
+ : mediaServerType === MediaServerType.JELLYFIN
+ ? 'jellyfin'
+ : 'emby'
+ } instance.`,
+ { label: 'AvailabilitySync' }
);
}
media.lastSeasonChange = new Date();
await mediaRepository.save(media);
-
- logger.info(
- `The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
- media.tmdbId
- }] was not found in any ${
- media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
- } and ${
- mediaServerType === MediaServerType.PLEX
- ? 'plex'
- : mediaServerType === MediaServerType.JELLYFIN
- ? 'jellyfin'
- : 'emby'
- } instance. Status will be changed to deleted.`,
- { label: 'AvailabilitySync' }
- );
} catch (ex) {
logger.debug(
`Failure updating the ${
@@ -604,7 +655,7 @@ class AvailabilitySync {
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
{
errorMessage: ex.message,
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
}
);
}
@@ -648,6 +699,10 @@ class AvailabilitySync {
});
}
+ if (radarr && radarr.tmdbId !== media.tmdbId) {
+ continue;
+ }
+
if (radarr && radarr.hasFile) {
const resolution =
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
@@ -671,7 +726,7 @@ class AvailabilitySync {
}] from Radarr.`,
{
errorMessage: ex.message,
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
}
);
}
@@ -705,18 +760,26 @@ class AvailabilitySync {
if (media.externalServiceId && !is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
- this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
- sonarr.seasons;
}
if (media.externalServiceId4k && is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
- this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
- sonarr.seasons;
}
- if (sonarr && sonarr.statistics.episodeFileCount > 0) {
- existsInSonarr = true;
+ if (sonarr && media.tvdbId != null && sonarr.tvdbId !== media.tvdbId) {
+ continue;
+ }
+
+ if (sonarr) {
+ const externalServiceId = is4k
+ ? media.externalServiceId4k
+ : media.externalServiceId;
+ this.sonarrSeasonsCache[`${server.id}-${externalServiceId}`] =
+ sonarr.seasons;
+
+ if (sonarr.statistics.episodeFileCount > 0) {
+ existsInSonarr = true;
+ }
}
} catch (ex) {
if (!ex.message.includes('404')) {
@@ -728,7 +791,7 @@ class AvailabilitySync {
}] from Sonarr.`,
{
errorMessage: ex.message,
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
}
);
}
@@ -827,6 +890,16 @@ class AvailabilitySync {
this.plexSeasonsCache[ratingKey] =
await this.plexClient?.getChildrenMetadata(ratingKey);
}
+
+ if (
+ plexMedia &&
+ media.mediaType === 'movie' &&
+ this.enable4kMovie &&
+ plexMedia.Media?.length &&
+ !plexMedia.Media.some((mediaItem) => (mediaItem.width ?? 0) < 2000)
+ ) {
+ plexMedia = undefined;
+ }
}
if (ratingKey4k && is4k) {
@@ -838,16 +911,11 @@ class AvailabilitySync {
}
if (plexMedia) {
- if (ratingKey === ratingKey4k) {
- plexMedia = undefined;
- }
-
if (
plexMedia &&
media.mediaType === 'movie' &&
- !plexMedia.Media?.some(
- (mediaItem) => (mediaItem.width ?? 0) >= 2000
- )
+ plexMedia.Media?.length &&
+ !plexMedia.Media.some((mediaItem) => (mediaItem.width ?? 0) >= 2000)
) {
plexMedia = undefined;
}
@@ -856,11 +924,15 @@ class AvailabilitySync {
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
if (cachedSeasons?.length) {
let has4kInAnySeason = false;
+ let verifiedAnySeason = false;
for (const season of cachedSeasons) {
try {
const episodes = await this.plexClient?.getChildrenMetadata(
season.ratingKey
);
+ if (episodes?.some((episode) => episode.Media?.length)) {
+ verifiedAnySeason = true;
+ }
const has4kEpisode = episodes?.some((episode) =>
episode.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
@@ -874,7 +946,7 @@ class AvailabilitySync {
// If we can't fetch episodes for a season, continue checking other seasons
}
}
- if (!has4kInAnySeason) {
+ if (verifiedAnySeason && !has4kInAnySeason) {
plexMedia = undefined;
}
}
@@ -895,7 +967,7 @@ class AvailabilitySync {
} [TMDB ID ${media.tmdbId}] from Plex.`,
{
errorMessage: ex.message,
- label: 'Availability Sync',
+ label: 'AvailabilitySync',
}
);
}
@@ -958,7 +1030,7 @@ class AvailabilitySync {
);
if (seasonMeta) {
- const cacheKey = seasonMeta.ratingKey;
+ const cacheKey = `${is4k ? '4k' : 'std'}-${seasonMeta.ratingKey}`;
if (cacheKey in this.plexEpisodeExistsCache) {
seasonExistsInPlex = this.plexEpisodeExistsCache[cacheKey];
@@ -970,8 +1042,20 @@ class AvailabilitySync {
seasonMeta.ratingKey
);
- seasonExistsInPlex =
- episodes?.some((episode) => episode.Media?.length > 0) ?? false;
+ const episodeVersions =
+ episodes?.flatMap((episode) => episode.Media ?? []) ?? [];
+
+ if (is4k) {
+ seasonExistsInPlex = episodeVersions.some(
+ (mediaItem) => (mediaItem.width ?? 0) >= 2000
+ );
+ } else if (this.enable4kShow) {
+ seasonExistsInPlex = episodeVersions.some(
+ (mediaItem) => (mediaItem.width ?? 0) < 2000
+ );
+ } else {
+ seasonExistsInPlex = episodeVersions.length > 0;
+ }
} catch {
// If we can't fetch episodes, assume the season exists
// to avoid false removal
diff --git a/server/lib/cache.ts b/server/lib/cache.ts
index 64b5c79ee8..3309abe64c 100644
--- a/server/lib/cache.ts
+++ b/server/lib/cache.ts
@@ -1,35 +1,217 @@
-import NodeCache from 'node-cache';
+import { LRUCache } from 'lru-cache';
export type AvailableCacheIds =
+ | 'hardcover'
| 'tmdb'
+ | 'tmdbscan'
| 'radarr'
| 'sonarr'
+ | 'readarr'
| 'rt'
| 'imdb'
| 'github'
- | 'plexguid'
| 'plextv'
| 'plexwatchlist'
| 'tvdb';
const DEFAULT_TTL = 300;
-const DEFAULT_CHECK_PERIOD = 120;
+
+const OBJECT_VALUE_SIZE = 80;
+const PROMISE_VALUE_SIZE = 80;
+const ARRAY_VALUE_SIZE = 40;
+
+// Limits key count, not memory, since TMDB never caps the credits rosters.
+const TMDB_MAX_KEYS = 1000;
+
+// Backstop against unbounded growth as the 15 minute TTL is what bounds this tier.
+const TMDB_SCAN_MAX_KEYS = 2000;
+
+// Only the rolling quality profile, root folder and language profile lookups reach
+// these tiers, and keys are prefixed by server url, so this is a few keys per server.
+// Readarr's metadata profiles are the same kind of lookup.
+const RADARR_MAX_KEYS = 64;
+const SONARR_MAX_KEYS = 64;
+const READARR_MAX_KEYS = 64;
+
+// One key per searched title, each holding twenty search hits.
+const RT_MAX_KEYS = 500;
+
+// One key per IMDb id, requested from the same endpoint as the RT ratings.
+const IMDB_MAX_KEYS = 500;
+
+// Releases and commits, varying only by page size and branch.
+const GITHUB_MAX_KEYS = 16;
+
+// Watchlist item metadata, shared between users as the token is not part of the key.
+const PLEX_TV_MAX_KEYS = 5000;
+
+// Keyed by auth token, so one key per Plex linked user.
+const PLEX_WATCHLIST_MAX_KEYS = 500;
+
+// Several keys per show, holding the largest payloads of any tier as the extended
+// series lookup carries every episode.
+const TVDB_MAX_KEYS = 500;
+
+// One key per book, series, author, search and discover page. Library scans look
+// every book up here, so this must stay above the size of a Readarr library or a
+// scan evicts its own lookups and re-fetches them at Hardcover's rate limit.
+const HARDCOVER_MAX_KEYS = 1000;
+
+export interface CacheStats {
+ hits: number;
+ misses: number;
+ keys: number;
+ ksize: number;
+ vsize: number;
+}
+
+export interface CacheStore {
+ get(key: string): T | undefined;
+ set(key: string, value: T, ttl?: number): boolean;
+ del(key: string): number;
+ getTtl(key: string): number | undefined;
+ getStats(): CacheStats;
+ flushAll(): void;
+}
+
+const keyLength = (key: string): number => key.toString().length;
+
+const valueLength = (value: unknown): number => {
+ if (typeof value === 'string') {
+ return value.length;
+ }
+ if (Array.isArray(value)) {
+ return ARRAY_VALUE_SIZE * value.length;
+ }
+ if (typeof value === 'number') {
+ return 8;
+ }
+ if (
+ typeof (value as { then?: unknown } | null | undefined)?.then === 'function'
+ ) {
+ return PROMISE_VALUE_SIZE;
+ }
+ if (Buffer.isBuffer(value)) {
+ return value.length;
+ }
+ if (value != null && typeof value === 'object') {
+ return OBJECT_VALUE_SIZE * Object.keys(value).length;
+ }
+ if (typeof value === 'boolean') {
+ return 8;
+ }
+ return 0;
+};
+
+interface LruEntry {
+ value: unknown;
+ ksize: number;
+ vsize: number;
+}
+
+class LruCacheStore implements CacheStore {
+ private cache: LRUCache;
+ private stdTtl: number;
+ private hits = 0;
+ private misses = 0;
+ private ksize = 0;
+ private vsize = 0;
+
+ constructor({ max, stdTtl }: { max: number; stdTtl: number }) {
+ this.stdTtl = stdTtl;
+ this.cache = new LRUCache({
+ max,
+ ttl: stdTtl * 1000,
+ // Without this, expired entries are only dropped when their key is read.
+ ttlAutopurge: true,
+ // Fires on every removal path, so one decrement here covers all of them.
+ dispose: (entry) => {
+ this.ksize -= entry.ksize;
+ this.vsize -= entry.vsize;
+ },
+ });
+ }
+
+ // Cloned on read because callers mutate what they get back, as getTvSeason
+ // does when it rewrites still_path in place.
+ public get(key: string): T | undefined {
+ const entry = this.cache.get(key);
+
+ if (entry === undefined) {
+ this.misses++;
+ return undefined;
+ }
+
+ this.hits++;
+ return structuredClone(entry.value) as T;
+ }
+
+ public set(key: string, value: T, ttl?: number): boolean {
+ const entry: LruEntry = {
+ value: structuredClone(value),
+ ksize: keyLength(key),
+ vsize: valueLength(value),
+ };
+
+ // A ttl of 0 means the entry never expires.
+ this.cache.set(key, entry, { ttl: (ttl ?? this.stdTtl) * 1000 });
+
+ this.ksize += entry.ksize;
+ this.vsize += entry.vsize;
+
+ return true;
+ }
+
+ public del(key: string): number {
+ return this.cache.delete(key) ? 1 : 0;
+ }
+
+ public getTtl(key: string): number | undefined {
+ if (!this.cache.has(key)) {
+ return undefined;
+ }
+
+ const remaining = this.cache.getRemainingTTL(key);
+
+ // Keys with no expiry report 0.
+ return remaining === Infinity ? 0 : Date.now() + remaining;
+ }
+
+ public getStats(): CacheStats {
+ return {
+ hits: this.hits,
+ misses: this.misses,
+ keys: this.cache.size,
+ ksize: this.ksize,
+ vsize: this.vsize,
+ };
+ }
+
+ public flushAll(): void {
+ this.cache.clear();
+ this.hits = 0;
+ this.misses = 0;
+ this.ksize = 0;
+ this.vsize = 0;
+ }
+}
class Cache {
public id: AvailableCacheIds;
- public data: NodeCache;
+ public data: CacheStore;
public name: string;
constructor(
id: AvailableCacheIds,
name: string,
- options: { stdTtl?: number; checkPeriod?: number } = {}
+ options: { max: number; stdTtl?: number }
) {
this.id = id;
this.name = name;
- this.data = new NodeCache({
- stdTTL: options.stdTtl ?? DEFAULT_TTL,
- checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD,
+
+ this.data = new LruCacheStore({
+ max: options.max,
+ stdTtl: options.stdTtl ?? DEFAULT_TTL,
});
}
@@ -46,34 +228,41 @@ class CacheManager {
private availableCaches: Record = {
tmdb: new Cache('tmdb', 'The Movie Database API', {
stdTtl: 21600,
- checkPeriod: 60 * 30,
+ max: TMDB_MAX_KEYS,
+ }),
+ tmdbscan: new Cache('tmdbscan', 'The Movie Database API (Library Scans)', {
+ stdTtl: 900,
+ max: TMDB_SCAN_MAX_KEYS,
}),
- radarr: new Cache('radarr', 'Radarr API'),
- sonarr: new Cache('sonarr', 'Sonarr API'),
+ radarr: new Cache('radarr', 'Radarr API', { max: RADARR_MAX_KEYS }),
+ sonarr: new Cache('sonarr', 'Sonarr API', { max: SONARR_MAX_KEYS }),
+ readarr: new Cache('readarr', 'Readarr API', { max: READARR_MAX_KEYS }),
rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200,
- checkPeriod: 60 * 30,
+ max: RT_MAX_KEYS,
}),
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
stdTtl: 43200,
- checkPeriod: 60 * 30,
+ max: IMDB_MAX_KEYS,
}),
github: new Cache('github', 'GitHub API', {
stdTtl: 21600,
- checkPeriod: 60 * 30,
- }),
- plexguid: new Cache('plexguid', 'Plex GUID', {
- stdTtl: 86400 * 7, // 1 week cache
- checkPeriod: 60 * 30,
+ max: GITHUB_MAX_KEYS,
}),
plextv: new Cache('plextv', 'Plex TV', {
stdTtl: 86400 * 7, // 1 week cache
- checkPeriod: 60,
+ max: PLEX_TV_MAX_KEYS,
+ }),
+ plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist', {
+ max: PLEX_WATCHLIST_MAX_KEYS,
}),
- plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
tvdb: new Cache('tvdb', 'The TVDB API', {
stdTtl: 21600,
- checkPeriod: 60 * 30,
+ max: TVDB_MAX_KEYS,
+ }),
+ hardcover: new Cache('hardcover', 'Hardcover API', {
+ stdTtl: 21600,
+ max: HARDCOVER_MAX_KEYS,
}),
};
diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts
index f160f6c89d..af38cb8710 100644
--- a/server/lib/downloadtracker.ts
+++ b/server/lib/downloadtracker.ts
@@ -1,4 +1,5 @@
import RadarrAPI from '@server/api/servarr/radarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaType } from '@server/constants/media';
import { getSettings } from '@server/lib/settings';
@@ -27,6 +28,7 @@ export interface DownloadingItem {
class DownloadTracker {
private radarrServers: Record = {};
private sonarrServers: Record = {};
+ private readarrServers: Record = {};
public getMovieProgress(
serverId: number,
@@ -54,14 +56,29 @@ class DownloadTracker {
);
}
+ public getBookProgress(
+ serverId: number,
+ externalServiceId: number
+ ): DownloadingItem[] {
+ if (!this.readarrServers[serverId]) {
+ return [];
+ }
+
+ return this.readarrServers[serverId].filter(
+ (item) => item.externalId === externalServiceId
+ );
+ }
+
public async resetDownloadTracker() {
this.radarrServers = {};
this.sonarrServers = {};
+ this.readarrServers = {};
}
public updateDownloads() {
this.updateRadarrDownloads();
this.updateSonarrDownloads();
+ this.updateReadarrDownloads();
}
private async updateRadarrDownloads() {
@@ -220,6 +237,84 @@ class DownloadTracker {
})
);
}
+
+ private async updateReadarrDownloads() {
+ const settings = getSettings();
+
+ // Remove duplicate servers
+ const filteredServers = uniqWith(settings.readarr, (readarrA, readarrB) => {
+ return (
+ readarrA.hostname === readarrB.hostname &&
+ readarrA.port === readarrB.port &&
+ readarrA.baseUrl === readarrB.baseUrl
+ );
+ });
+
+ // Load downloads from Readarr servers
+ Promise.all(
+ filteredServers.map(async (server) => {
+ if (server.syncEnabled) {
+ const readarr = new ReadarrAPI({
+ apiKey: server.apiKey,
+ url: ReadarrAPI.buildUrl(server, '/api/v1'),
+ });
+
+ try {
+ await readarr.refreshMonitoredDownloads();
+ const queueItems = await readarr.getQueue();
+
+ this.readarrServers[server.id] = queueItems.map((item) => ({
+ externalId: item.bookId,
+ estimatedCompletionTime: new Date(item.estimatedCompletionTime),
+ mediaType: MediaType.BOOK,
+ size: item.size,
+ sizeLeft: item.sizeleft,
+ status: item.status,
+ timeLeft: item.timeleft,
+ title: item.title,
+ downloadId: item.downloadId,
+ }));
+
+ if (queueItems.length > 0) {
+ logger.debug(
+ `Found ${queueItems.length} item(s) in progress on Readarr server: ${server.name}`,
+ { label: 'Download Tracker' }
+ );
+ }
+ } catch {
+ logger.error(
+ `Unable to get queue from Readarr server: ${server.name}`,
+ {
+ label: 'Download Tracker',
+ }
+ );
+ }
+
+ // Duplicate this data to matching servers
+ const matchingServers = settings.readarr.filter(
+ (rs) =>
+ rs.hostname === server.hostname &&
+ rs.port === server.port &&
+ rs.baseUrl === server.baseUrl &&
+ rs.id !== server.id
+ );
+
+ if (matchingServers.length > 0) {
+ logger.debug(
+ `Matching download data to ${matchingServers.length} other Readarr server(s)`,
+ { label: 'Download Tracker' }
+ );
+ }
+
+ matchingServers.forEach((ms) => {
+ if (ms.syncEnabled) {
+ this.readarrServers[ms.id] = this.readarrServers[server.id];
+ }
+ });
+ }
+ })
+ );
+ }
}
const downloadTracker = new DownloadTracker();
diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts
index 66b3698faf..d34e2c9b75 100644
--- a/server/lib/email/index.ts
+++ b/server/lib/email/index.ts
@@ -1,10 +1,48 @@
import type { NotificationAgentEmail } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import Email from 'email-templates';
+import net from 'node:net';
import nodemailer from 'nodemailer';
+import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { URL } from 'url';
import { openpgpEncrypt } from './openpgpEncrypt';
+const getSocket: SMTPTransport.Options['getSocket'] = (options, callback) => {
+ if (!options.host || typeof options.port !== 'number') {
+ callback(new Error('SMTP host and port are required'), undefined);
+ return;
+ }
+
+ const socket = net.connect({
+ host: options.host,
+ port: options.port,
+ });
+ const cleanup = () => {
+ socket.setTimeout(0);
+ socket.removeListener('error', onError);
+ socket.removeListener('connect', onConnect);
+ socket.removeListener('timeout', onTimeout);
+ };
+ const onError = (error: Error) => {
+ cleanup();
+ callback(error, undefined);
+ };
+ const onConnect = () => {
+ cleanup();
+ callback(null, { connection: socket });
+ };
+ const onTimeout = () => {
+ cleanup();
+ socket.destroy();
+ callback(new Error('SMTP connection timed out'), undefined);
+ };
+
+ socket.once('error', onError);
+ socket.once('connect', onConnect);
+ socket.once('timeout', onTimeout);
+ socket.setTimeout(10000);
+};
+
class PreparedEmail extends Email {
public constructor(settings: NotificationAgentEmail, pgpKey?: string) {
const { applicationUrl } = getSettings().main;
@@ -28,6 +66,7 @@ class PreparedEmail extends Email {
pass: settings.options.authPass,
}
: undefined,
+ getSocket: net.isIP(settings.options.smtpHost) ? undefined : getSocket,
});
if (pgpKey) {
diff --git a/server/lib/forkSchema.ts b/server/lib/forkSchema.ts
new file mode 100644
index 0000000000..d2b530d6c3
--- /dev/null
+++ b/server/lib/forkSchema.ts
@@ -0,0 +1,290 @@
+import logger from '@server/logger';
+import type { DataSource } from 'typeorm';
+
+export interface ForkColumn {
+ table: string;
+ column: string;
+ type: string;
+}
+
+export const FORK_COLUMNS: ForkColumn[] = [
+ { table: 'media_request', column: 'metadataProfileId', type: 'integer' },
+ { table: 'override_rule', column: 'readarrServiceId', type: 'integer' },
+ { table: 'override_rule', column: 'metadataProfileId', type: 'integer' },
+ { table: 'user', column: 'bookQuotaLimit', type: 'integer' },
+ { table: 'user', column: 'bookQuotaDays', type: 'integer' },
+];
+
+const BACKUP_PREFIX = '_fork_backup_';
+const LOG_LABEL = 'Fork Schema';
+
+export const applyForkSchema = async (
+ dbConnection: DataSource
+): Promise => {
+ await abortIfOldForkDatabase(dbConnection);
+ await snapshotForkColumns(dbConnection);
+ await runUpstreamMigrations(dbConnection);
+ await ensureForkColumns(dbConnection);
+ await ensurePermissionsColumnIsBigint(dbConnection);
+ await restoreForkColumns(dbConnection);
+};
+
+// Check for old fork user
+const abortIfOldForkDatabase = async (
+ dbConnection: DataSource
+): Promise => {
+ try {
+ const oldForkMarkers: { table: string; column: string }[] = [
+ { table: 'media', column: 'hcId' },
+ { table: 'media_request', column: 'isAlt' },
+ { table: 'blocklist', column: 'externalId' },
+ ];
+
+ for (const marker of oldForkMarkers) {
+ if (!(await tableExists(dbConnection, marker.table))) {
+ continue;
+ }
+ const columns = await getColumnNames(dbConnection, marker.table);
+ if (columns.includes(marker.column)) {
+ logger.error(
+ `This database was created by an old version of the fork (found "${marker.table}"."${marker.column}"), which this version can no longer upgrade. Please upgrade to v3.2.0 first, or start with a fresh database.`,
+ { label: LOG_LABEL }
+ );
+ process.exit(1);
+ }
+ }
+ } catch (error) {
+ logger.error('Failed to check for old fork database', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+};
+
+const snapshotForkColumns = async (dbConnection: DataSource): Promise => {
+ let hasPendingMigrations = false;
+ try {
+ hasPendingMigrations = await dbConnection.showMigrations();
+ } catch (error) {
+ logger.error('Failed to check for pending migrations', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+
+ if (!hasPendingMigrations) {
+ return;
+ }
+
+ try {
+ for (const table of forkTables()) {
+ const backupTable = `${BACKUP_PREFIX}${table}`;
+
+ if (await tableExists(dbConnection, backupTable)) {
+ logger.warn(
+ `Found existing backup table "${backupTable}" from an interrupted upgrade, keeping it`,
+ { label: LOG_LABEL }
+ );
+ continue;
+ }
+
+ // the table does not exist yet, nothing to back up, fresh install
+ if (!(await tableExists(dbConnection, table))) {
+ continue;
+ }
+
+ const existingColumns = await getColumnNames(dbConnection, table);
+ const columnsToBackUp = forkColumnsFor(table).filter((forkColumn) =>
+ existingColumns.includes(forkColumn.column)
+ );
+
+ // no fork columns, existing seerr user migrating to fork
+ if (columnsToBackUp.length === 0) {
+ continue;
+ }
+
+ logger.info(
+ `Backing up fork columns of "${table}" before running pending migrations`,
+ { label: LOG_LABEL }
+ );
+
+ const columnList = columnsToBackUp
+ .map((forkColumn) => `"${forkColumn.column}"`)
+ .join(', ');
+ await dbConnection.query(
+ `CREATE TABLE "${backupTable}" AS SELECT "id", ${columnList} FROM "${table}"`
+ );
+
+ await dbConnection.query(
+ `CREATE INDEX "${backupTable}_id_idx" ON "${backupTable}" ("id")`
+ );
+ }
+ } catch (error) {
+ logger.error('Failed to back up fork columns', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+};
+
+const runUpstreamMigrations = async (
+ dbConnection: DataSource
+): Promise => {
+ try {
+ await dbConnection.runMigrations();
+ } catch (error) {
+ logger.error('Failed to run database migrations', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+};
+
+const ensureForkColumns = async (dbConnection: DataSource): Promise => {
+ try {
+ for (const forkColumn of FORK_COLUMNS) {
+ if (!(await tableExists(dbConnection, forkColumn.table))) {
+ logger.warn(
+ `Expected table "${forkColumn.table}" does not exist, skipping fork column "${forkColumn.column}"`,
+ { label: LOG_LABEL }
+ );
+ continue;
+ }
+
+ const existingColumns = await getColumnNames(
+ dbConnection,
+ forkColumn.table
+ );
+ if (existingColumns.includes(forkColumn.column)) {
+ continue;
+ }
+
+ logger.info(
+ `Adding fork column "${forkColumn.table}"."${forkColumn.column}"`,
+ { label: LOG_LABEL }
+ );
+ await dbConnection.query(
+ `ALTER TABLE "${forkColumn.table}" ADD COLUMN "${forkColumn.column}" ${forkColumn.type}`
+ );
+ }
+ } catch (error) {
+ logger.error('Failed to add fork columns', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+};
+
+const ensurePermissionsColumnIsBigint = async (
+ dbConnection: DataSource
+): Promise => {
+ if (dbConnection.options.type !== 'postgres') {
+ return;
+ }
+
+ try {
+ const rows: { data_type: string }[] = await dbConnection.query(
+ `SELECT data_type FROM information_schema.columns WHERE table_name = 'user' AND column_name = 'permissions'`
+ );
+ if (rows.length > 0 && rows[0].data_type === 'integer') {
+ logger.info('Widening "user"."permissions" to bigint', {
+ label: LOG_LABEL,
+ });
+ await dbConnection.query(
+ `ALTER TABLE "user" ALTER COLUMN "permissions" TYPE bigint`
+ );
+ }
+ } catch (error) {
+ logger.error('Failed to widen "user"."permissions" to bigint', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+};
+
+const restoreForkColumns = async (dbConnection: DataSource): Promise => {
+ try {
+ for (const table of forkTables()) {
+ const backupTable = `${BACKUP_PREFIX}${table}`;
+ if (!(await tableExists(dbConnection, backupTable))) {
+ continue;
+ }
+
+ logger.info(`Restoring fork columns of "${table}" from backup`, {
+ label: LOG_LABEL,
+ });
+
+ // Only restore columns that are both in the backup and in the current
+ // manifest, so a backup written by an older fork version stays valid
+ const backupColumns = await getColumnNames(dbConnection, backupTable);
+ const columnsToRestore = forkColumnsFor(table).filter((forkColumn) =>
+ backupColumns.includes(forkColumn.column)
+ );
+
+ for (const forkColumn of columnsToRestore) {
+ await dbConnection.query(
+ `UPDATE "${table}" SET "${forkColumn.column}" = (SELECT b."${forkColumn.column}" FROM "${backupTable}" b WHERE b."id" = "${table}"."id")`
+ );
+ }
+
+ await dbConnection.query(`DROP TABLE "${backupTable}"`);
+ }
+ } catch (error) {
+ logger.error('Failed to restore fork columns', {
+ label: LOG_LABEL,
+ error: error.message,
+ });
+ process.exit(1);
+ }
+};
+
+const forkTables = (): string[] => {
+ return Array.from(
+ new Set(FORK_COLUMNS.map((forkColumn) => forkColumn.table))
+ );
+};
+
+const forkColumnsFor = (table: string): ForkColumn[] => {
+ return FORK_COLUMNS.filter((forkColumn) => forkColumn.table === table);
+};
+
+const tableExists = async (
+ dbConnection: DataSource,
+ table: string
+): Promise => {
+ if (dbConnection.options.type === 'postgres') {
+ const rows = await dbConnection.query(
+ `SELECT 1 FROM information_schema.tables WHERE table_name = $1`,
+ [table]
+ );
+ return rows.length > 0;
+ }
+ const rows = await dbConnection.query(
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
+ [table]
+ );
+ return rows.length > 0;
+};
+
+const getColumnNames = async (
+ dbConnection: DataSource,
+ table: string
+): Promise => {
+ if (dbConnection.options.type === 'postgres') {
+ const rows: { column_name: string }[] = await dbConnection.query(
+ `SELECT column_name FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position`,
+ [table]
+ );
+ return rows.map((row) => row.column_name);
+ }
+ const rows: { name: string }[] = await dbConnection.query(
+ `PRAGMA table_info("${table}")`
+ );
+ return rows.map((row) => row.name);
+};
diff --git a/server/lib/imageproxy.test.ts b/server/lib/imageproxy.test.ts
new file mode 100644
index 0000000000..06c6946776
--- /dev/null
+++ b/server/lib/imageproxy.test.ts
@@ -0,0 +1,107 @@
+import ImageProxy from '@server/lib/imageproxy';
+import axios, { type AxiosResponse } from 'axios';
+import assert from 'node:assert/strict';
+import { after, afterEach, before, beforeEach, describe, it } from 'node:test';
+import sharp from 'sharp';
+
+type Transform = (buffer: Buffer) => Promise<{
+ buffer: Buffer;
+ extension: string;
+}>;
+
+const optimize: Transform = async (buffer) => ({
+ buffer: await sharp(buffer)
+ .resize(600, 900, { fit: 'cover' })
+ .webp({ quality: 80 })
+ .toBuffer(),
+ extension: 'webp',
+});
+
+describe('image proxy transform', () => {
+ let original: Buffer;
+ let previousAdapter: typeof axios.defaults.adapter;
+ const cached: [ImageProxy, string][] = [];
+
+ const freshProxy = async (transform: Transform, path: string) => {
+ const proxy = new ImageProxy('imageproxy-test', 'https://images.test', {
+ transform,
+ });
+ await proxy.clearCachedImage(path);
+ cached.push([proxy, path]);
+ return proxy;
+ };
+
+ before(async () => {
+ original = await sharp({
+ create: {
+ width: 1200,
+ height: 1800,
+ channels: 3,
+ background: '#808080',
+ },
+ })
+ .jpeg()
+ .toBuffer();
+ });
+
+ beforeEach(() => {
+ previousAdapter = axios.defaults.adapter;
+ axios.defaults.adapter = (config) =>
+ Promise.resolve({
+ data: original,
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'content-type': 'image/jpeg',
+ 'cache-control': 'max-age=3600',
+ },
+ config,
+ request: {},
+ } as AxiosResponse);
+ });
+
+ afterEach(() => {
+ axios.defaults.adapter = previousAdapter;
+ });
+
+ after(async () => {
+ for (const [proxy, path] of cached) {
+ await proxy.clearCachedImage(path);
+ }
+ });
+
+ it('serves the optimized image on the first request', async () => {
+ const proxy = await freshProxy(optimize, '/first.jpg');
+
+ const image = await proxy.getImage('/first.jpg');
+
+ assert.equal(image.meta.cacheMiss, true);
+ assert.equal(image.meta.extension, 'webp');
+ const { format, width, height } = await sharp(image.imageBuffer).metadata();
+ assert.deepEqual(
+ { format, width, height },
+ { format: 'webp', width: 600, height: 900 }
+ );
+ });
+
+ it('serves the same optimized image from the cache afterwards', async () => {
+ const proxy = await freshProxy(optimize, '/repeat.jpg');
+
+ const first = await proxy.getImage('/repeat.jpg');
+ const second = await proxy.getImage('/repeat.jpg');
+
+ assert.equal(second.meta.cacheMiss, false);
+ assert.equal(second.meta.extension, 'webp');
+ assert.deepEqual(second.imageBuffer, first.imageBuffer);
+ });
+
+ it('falls back to the original when optimizing fails', async () => {
+ const proxy = await freshProxy(async () => {
+ throw new Error('unsupported image');
+ }, '/broken.jpg');
+
+ const image = await proxy.getImage('/broken.jpg');
+
+ assert.deepEqual(image.imageBuffer, original);
+ });
+});
diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts
index c485c10291..73e7c99d98 100644
--- a/server/lib/imageproxy.ts
+++ b/server/lib/imageproxy.ts
@@ -1,10 +1,11 @@
import logger from '@server/logger';
-import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
+import { proxyRequestInterceptor } from '@server/utils/customProxyAgent';
+import { userAgentRequestInterceptor } from '@server/utils/userAgent';
import axios, { type AxiosInstance } from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
import { promises } from 'fs';
-import mime from 'mime/lite';
+import mime from 'mime';
import path, { join } from 'path';
type ImageResponse = {
@@ -131,6 +132,9 @@ class ImageProxy {
private axios: AxiosInstance;
private cacheVersion;
private key;
+ private transform?: (
+ buffer: Buffer
+ ) => Promise<{ buffer: Buffer; extension: string }>;
constructor(
key: string,
@@ -139,15 +143,20 @@ class ImageProxy {
cacheVersion?: number;
rateLimitOptions?: rateLimitOptions;
headers?: Record;
+ transform?: (
+ buffer: Buffer
+ ) => Promise<{ buffer: Buffer; extension: string }>;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.key = key;
+ this.transform = options.transform;
this.axios = axios.create({
baseURL: baseUrl,
headers: options.headers,
});
- this.axios.interceptors.request.use(requestInterceptorFunction);
+ this.axios.interceptors.request.use(proxyRequestInterceptor);
+ this.axios.interceptors.request.use(userAgentRequestInterceptor);
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);
@@ -173,17 +182,62 @@ class ImageProxy {
}
}
+ if (this.transform && newImage.meta.cacheMiss) {
+ return (await this.optimizeAndCache(newImage, cacheKey)) ?? newImage;
+ }
+
return newImage;
}
// If the image is stale, we will revalidate it in the background.
if (imageResponse.meta.isStale) {
- this.set(path, cacheKey);
+ this.revalidate(path, cacheKey);
}
return imageResponse;
}
+ // Refreshing writes the original back over the cache entry, so anything that
+ // was optimized has to be optimized again or it reverts on every expiry
+ private async revalidate(path: string, cacheKey: string): Promise {
+ const refreshed = await this.set(path, cacheKey);
+
+ if (this.transform && refreshed) {
+ await this.optimizeAndCache(refreshed, cacheKey);
+ }
+ }
+
+ private async optimizeAndCache(
+ image: ImageResponse,
+ cacheKey: string
+ ): Promise {
+ if (!this.transform) return null;
+
+ try {
+ const { buffer, extension } = await this.transform(image.imageBuffer);
+ const directory = join(this.getCacheDirectory(), cacheKey);
+ const expireAt = Date.now() + image.meta.curRevalidate * 1000;
+
+ await this.writeToCacheDir(
+ directory,
+ extension,
+ image.meta.curRevalidate,
+ expireAt,
+ buffer,
+ image.meta.etag
+ );
+
+ return { meta: { ...image.meta, extension }, imageBuffer: buffer };
+ } catch (e) {
+ logger.debug('Failed to optimize image.', {
+ label: 'Image Cache',
+ errorMessage: e.message,
+ });
+
+ return null;
+ }
+ }
+
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
@@ -270,15 +324,18 @@ class ImageProxy {
const buffer = Buffer.from(response.data, 'binary');
const contentType = response.headers['content-type'] || '';
- const extension = mime.getExtension(contentType) || '';
+ const extension = (mime.getExtension(contentType) || '').replace(
+ /[^\w-]/g,
+ ''
+ );
let maxAge = Number(
(response.headers['cache-control'] ?? '0').split('=')[1]
);
- if (!maxAge) maxAge = 86400;
+ if (!maxAge) maxAge = 604800;
const expireAt = Date.now() + maxAge * 1000;
- const etag = (response.headers.etag ?? '').replace(/"/g, '');
+ const etag = (response.headers.etag ?? '').replace(/[^\w-]/g, '');
await this.writeToCacheDir(
directory,
diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts
index d37bc6e58f..81da3c4c40 100644
--- a/server/lib/notifications/agents/discord.ts
+++ b/server/lib/notifications/agents/discord.ts
@@ -1,3 +1,7 @@
+import {
+ DISCORD_SNOWFLAKE_REGEX,
+ EmbedColors,
+} from '@server/constants/discord';
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
@@ -16,31 +20,8 @@ import {
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
-enum EmbedColors {
- DEFAULT = 0,
- AQUA = 1752220,
- GREEN = 3066993,
- BLUE = 3447003,
- PURPLE = 10181046,
- GOLD = 15844367,
- ORANGE = 15105570,
- RED = 15158332,
- GREY = 9807270,
- DARKER_GREY = 8359053,
- NAVY = 3426654,
- DARK_AQUA = 1146986,
- DARK_GREEN = 2067276,
- DARK_BLUE = 2123412,
- DARK_PURPLE = 7419530,
- DARK_GOLD = 12745742,
- DARK_ORANGE = 11027200,
- DARK_RED = 10038562,
- DARK_GREY = 9936031,
- LIGHT_GREY = 12370112,
- DARK_NAVY = 2899536,
- LUMINOUS_VIVID_PINK = 16580705,
- DARK_VIVID_PINK = 12320855,
-}
+const isValidSnowflake = (id: string): boolean =>
+ DISCORD_SNOWFLAKE_REGEX.test(id);
interface DiscordImageEmbed {
url?: string;
@@ -278,9 +259,12 @@ class DiscordAgent
NotificationAgentKey.DISCORD,
type
) &&
- payload.notifyUser.settings.discordId
+ payload.notifyUser.settings.discordIds?.length
) {
- userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
+ const validIds = payload.notifyUser.settings.discordIds.filter(
+ (id) => isValidSnowflake(id)
+ );
+ userMentions.push(...validIds.map((id) => `<@${id}>`));
}
}
@@ -296,16 +280,30 @@ class DiscordAgent
NotificationAgentKey.DISCORD,
type
) &&
- user.settings.discordId &&
+ user.settings.discordIds?.length &&
shouldSendAdminNotification(type, user, payload)
)
- .map((user) => `<@${user.settings?.discordId}>`)
+ .flatMap((user) =>
+ user
+ .settings!.discordIds.filter((id) => isValidSnowflake(id))
+ .map((id) => `<@${id}>`)
+ )
);
}
}
- if (settings.options.webhookRoleId) {
+ const allowedUserIds = userMentions.map((mention) =>
+ mention.replace(/[<@>]/g, '')
+ );
+
+ const allowedRoleIds: string[] = [];
+
+ if (
+ settings.options.webhookRoleId &&
+ isValidSnowflake(settings.options.webhookRoleId)
+ ) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
+ allowedRoleIds.push(settings.options.webhookRoleId);
}
// Discord webhooks go to a channel, not per-user,
@@ -315,13 +313,25 @@ class DiscordAgent
? (payload.notifyUser?.settings?.locale as AvailableLocale)
: (settings.options.locale as AvailableLocale);
- await axios.post(settings.options.webhookUrl, {
+ const webhookUrl = new URL(settings.options.webhookUrl);
+ if (settings.options.webhookThreadId) {
+ webhookUrl.searchParams.set(
+ 'thread_id',
+ settings.options.webhookThreadId
+ );
+ }
+
+ await axios.post(webhookUrl.toString(), {
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload, locale)],
content: userMentions.join(' '),
+ allowed_mentions: {
+ users: allowedUserIds,
+ roles: allowedRoleIds,
+ },
} as DiscordWebhookPayload);
return true;
diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts
index 4a82b3943b..3066cecadc 100644
--- a/server/lib/notifications/agents/email.ts
+++ b/server/lib/notifications/agents/email.ts
@@ -16,6 +16,9 @@ import { Notification, shouldSendAdminNotification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
+const PUBLIC_LOGO_URL =
+ 'https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/public/logo_full.svg';
+
const messages = defineMessages('notifications.agents.email', {
issueType: '{type} issue',
issue: 'issue',
@@ -96,6 +99,12 @@ class EmailAgent
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.email;
+ const { usePublicLogo } = settings.notifications.agents.email.options;
+ const logoUrl = usePublicLogo
+ ? PUBLIC_LOGO_URL
+ : applicationUrl
+ ? `${applicationUrl}/logo_full.svg`
+ : undefined;
if (type === Notification.TEST_NOTIFICATION) {
return {
@@ -107,18 +116,29 @@ class EmailAgent
body: payload.message,
applicationUrl,
applicationTitle,
+ logoUrl,
recipientName,
recipientEmail,
},
};
}
+ const is4k = payload.request?.is4k;
+ const isBook = payload.media?.mediaType === MediaType.BOOK;
+ // Books reuse the is4k flag to mean "audiobook". Upstream encodes the 4K
+ // variant by suffixing " in 4K", which does not apply to audiobooks, so we
+ // fold the audiobook wording into the mediaType noun and never select the
+ // 4K message variant for books (see use4k below).
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? intl.formatMessage(globalMessages.movie)
- : intl.formatMessage(globalMessages.series)
+ : payload.media.mediaType === MediaType.BOOK
+ ? intl.formatMessage(
+ is4k ? globalMessages.audiobook : globalMessages.book
+ )
+ : intl.formatMessage(globalMessages.series)
: undefined;
- const is4k = payload.request?.is4k;
+ const use4k = is4k && !isBook;
if (payload.request) {
let body = '';
@@ -126,49 +146,51 @@ class EmailAgent
switch (type) {
case Notification.MEDIA_PENDING:
body = intl.formatMessage(
- is4k ? messages.pendingRequest4k : messages.pendingRequest,
+ use4k ? messages.pendingRequest4k : messages.pendingRequest,
{ mediaType }
);
break;
case Notification.MEDIA_AUTO_REQUESTED:
body = intl.formatMessage(
- is4k ? messages.autoRequested4k : messages.autoRequested,
+ use4k ? messages.autoRequested4k : messages.autoRequested,
{ mediaType }
);
break;
case Notification.MEDIA_APPROVED:
body = intl.formatMessage(
- is4k ? messages.approvedRequest4k : messages.approvedRequest,
+ use4k ? messages.approvedRequest4k : messages.approvedRequest,
{ mediaType }
);
break;
case Notification.MEDIA_AUTO_APPROVED:
body = intl.formatMessage(
- is4k ? messages.autoApproved4k : messages.autoApproved,
+ use4k ? messages.autoApproved4k : messages.autoApproved,
{ mediaType }
);
break;
case Notification.MEDIA_AVAILABLE:
body = intl.formatMessage(
- is4k ? messages.availableRequest4k : messages.availableRequest,
+ use4k ? messages.availableRequest4k : messages.availableRequest,
{ mediaType }
);
break;
case Notification.MEDIA_DECLINED:
body = intl.formatMessage(
- is4k ? messages.declinedRequest4k : messages.declinedRequest,
+ use4k ? messages.declinedRequest4k : messages.declinedRequest,
{ mediaType }
);
break;
case Notification.MEDIA_FAILED:
body = intl.formatMessage(
- is4k ? messages.failedRequest4k : messages.failedRequest,
+ use4k ? messages.failedRequest4k : messages.failedRequest,
{
mediaType,
service:
payload.media?.mediaType === MediaType.MOVIE
? 'Radarr'
- : 'Sonarr',
+ : payload.media?.mediaType === MediaType.BOOK
+ ? 'Readarr'
+ : 'Sonarr',
}
);
break;
@@ -195,6 +217,7 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
+ logoUrl,
recipientName,
recipientEmail,
},
@@ -263,6 +286,7 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
+ logoUrl,
recipientName,
recipientEmail,
},
diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts
index 0470e156e0..8384ab27a6 100644
--- a/server/lib/notifications/agents/gotify.ts
+++ b/server/lib/notifications/agents/gotify.ts
@@ -52,6 +52,7 @@ class GotifyAgent
const settings = this.getSettings();
const intl = getIntl(settings.options.locale);
const { applicationUrl, applicationTitle } = getSettings().main;
+ const embedPoster = settings.embedPoster;
const priority = settings.options.priority ?? 1;
const title = payload.event
@@ -102,6 +103,10 @@ class GotifyAgent
message += `\n\n**${extra.name}**\n${extra.value} `;
}
+ if (embedPoster && payload.image) {
+ message += `\n\n `;
+ }
+
if (applicationUrl && payload.media) {
const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
const displayUrl =
@@ -114,6 +119,13 @@ class GotifyAgent
'client::display': {
contentType: 'text/markdown',
},
+ ...(embedPoster && payload.image
+ ? {
+ 'client::notification': {
+ bigImageUrl: payload.image,
+ },
+ }
+ : {}),
},
title,
message,
diff --git a/server/lib/notifications/agents/ntfy.ts b/server/lib/notifications/agents/ntfy.ts
index fae329a007..c9efcbe2f6 100644
--- a/server/lib/notifications/agents/ntfy.ts
+++ b/server/lib/notifications/agents/ntfy.ts
@@ -35,6 +35,10 @@ class NtfyAgent
const embedPoster = settings.embedPoster;
const topic = settings.options.topic;
+ const tags = settings.options.tags
+ ?.split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => tag.length > 0);
const priority = settings.options.priority ?? 3;
const title = payload.event
@@ -102,6 +106,9 @@ class NtfyAgent
message,
markdown: true,
};
+ if (tags && tags.length > 0) {
+ ntfyPayload.tags = tags;
+ }
if (attach) {
ntfyPayload.attach = attach;
}
diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts
index a7a80595b9..3fdde03ab8 100644
--- a/server/lib/notifications/agents/webhook.ts
+++ b/server/lib/notifications/agents/webhook.ts
@@ -23,7 +23,7 @@ const KeyMap: Record = {
notifyuser_username: 'notifyUser.displayName',
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
- notifyuser_settings_discordId: 'notifyUser.settings.discordId',
+ notifyuser_settings_discordIds: 'notifyUser.settings.discordIds',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_imdbid: 'media.imdbId',
media_tmdbid: 'media.tmdbId',
@@ -42,7 +42,7 @@ const KeyMap: Record = {
requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email',
requestedBy_avatar: 'request.requestedBy.avatar',
- requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
+ requestedBy_settings_discordIds: 'request.requestedBy.settings.discordIds',
requestedBy_settings_telegramChatId:
'request.requestedBy.settings.telegramChatId',
issue_id: 'issue.id',
@@ -53,13 +53,13 @@ const KeyMap: Record = {
reportedBy_username: 'issue.createdBy.displayName',
reportedBy_email: 'issue.createdBy.email',
reportedBy_avatar: 'issue.createdBy.avatar',
- reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
+ reportedBy_settings_discordIds: 'issue.createdBy.settings.discordIds',
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
comment_message: 'comment.message',
commentedBy_username: 'comment.user.displayName',
commentedBy_email: 'comment.user.email',
commentedBy_avatar: 'comment.user.avatar',
- commentedBy_settings_discordId: 'comment.user.settings.discordId',
+ commentedBy_settings_discordIds: 'comment.user.settings.discordIds',
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
};
diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts
index 8586dd2960..b67561dbd6 100644
--- a/server/lib/notifications/agents/webpush.ts
+++ b/server/lib/notifications/agents/webpush.ts
@@ -79,10 +79,15 @@ class WebPushAgent
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? intl.formatMessage(globalMessages.movie)
- : intl.formatMessage(globalMessages.series)
+ : payload.media.mediaType === MediaType.BOOK
+ ? intl.formatMessage(globalMessages.book)
+ : intl.formatMessage(globalMessages.series)
: undefined;
const is4k = payload.request?.is4k;
- const quality = is4k ? '4K ' : '';
+ const isBook = payload.media?.mediaType === MediaType.BOOK;
+ // Books reuse the is4k flag to mean "audiobook"; render "audiobook" (no
+ // trailing space so it composes to one word) instead of "4K ".
+ const quality = is4k ? (isBook ? 'audio' : '4K ') : '';
const issueType = payload.issue
? payload.issue.issueType !== IssueType.OTHER
diff --git a/server/lib/overrideRules.ts b/server/lib/overrideRules.ts
new file mode 100644
index 0000000000..3d31876ca9
--- /dev/null
+++ b/server/lib/overrideRules.ts
@@ -0,0 +1,166 @@
+import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
+import type {
+ TmdbKeyword,
+ TmdbMovieDetails,
+ TmdbTvDetails,
+} from '@server/api/themoviedb/interfaces';
+import { MediaType } from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import OverrideRule from '@server/entity/OverrideRule';
+import type { User } from '@server/entity/User';
+import { getSettings } from '@server/lib/settings';
+
+export type OverrideRulesResult = {
+ rootFolder: string | null;
+ profileId: number | null;
+ tags: number[] | null;
+};
+
+async function overrideRules({
+ mediaType,
+ is4k,
+ tmdbMedia,
+ requestUser,
+ tags = null,
+ serviceId,
+}: {
+ mediaType: MediaType;
+ is4k: boolean;
+ tmdbMedia: TmdbMovieDetails | TmdbTvDetails;
+ requestUser: User;
+ tags?: number[] | null;
+ serviceId?: number;
+}): Promise {
+ const settings = getSettings();
+
+ let rootFolder: string | null = null;
+ let profileId: number | null = null;
+
+ const serviceIdField =
+ mediaType === MediaType.MOVIE ? 'radarrServiceId' : 'sonarrServiceId';
+ if (serviceId === undefined) {
+ const defaultRadarrId = is4k
+ ? settings.radarr.find((r) => r.is4k && r.isDefault)?.id
+ : settings.radarr.find((r) => !r.is4k && r.isDefault)?.id;
+ const defaultSonarrId = is4k
+ ? settings.sonarr.find((s) => s.is4k && s.isDefault)?.id
+ : settings.sonarr.find((s) => !s.is4k && s.isDefault)?.id;
+ serviceId =
+ mediaType === MediaType.MOVIE ? defaultRadarrId : defaultSonarrId;
+ }
+
+ const overrideRuleRepository = getRepository(OverrideRule);
+ const rules =
+ serviceId === undefined
+ ? []
+ : await overrideRuleRepository.find({
+ where: { [serviceIdField]: serviceId },
+ });
+
+ const appliedOverrideRules = rules.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 (
+ 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 hasSpecificOverrideValue = (
+ rule: OverrideRule,
+ key: keyof OverrideRule
+ ) => {
+ const value = rule[key];
+ if (value == null) return false;
+ return typeof value !== 'string' || value.trim() !== '';
+ };
+ const prioritizedRule = appliedOverrideRules.sort((a, b) => {
+ const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
+ const aSpecificity = keys.filter((key) =>
+ hasSpecificOverrideValue(a, key)
+ ).length;
+ const bSpecificity = keys.filter((key) =>
+ hasSpecificOverrideValue(b, key)
+ ).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)),
+ ]),
+ ];
+ }
+ }
+
+ return { rootFolder, profileId, tags };
+}
+
+export default overrideRules;
diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts
index edc9f7e183..46dc2e49cf 100644
--- a/server/lib/permissions.ts
+++ b/server/lib/permissions.ts
@@ -29,6 +29,10 @@ export enum Permission {
WATCHLIST_VIEW = 134217728,
MANAGE_BLOCKLIST = 268435456,
VIEW_BLOCKLIST = 1073741824,
+ REQUEST_BOOK = 2147483648,
+ AUTO_APPROVE_BOOK = 4294967296,
+ REQUEST_AUDIO_BOOK = 8589934592,
+ AUTO_APPROVE_AUDIO_BOOK = 17179869184,
}
export interface PermissionCheckOptions {
@@ -51,24 +55,35 @@ export const hasPermission = (
): boolean => {
let total = 0;
- // If we are not checking any permissions, bail out and return true
if (permissions === 0) {
return true;
}
+ if (isNaN(value)) {
+ return false;
+ }
+
+ const bigValue = BigInt(value);
+
if (Array.isArray(permissions)) {
- if (value & Permission.ADMIN) {
+ if (bigValue & BigInt(Permission.ADMIN)) {
return true;
}
switch (options.type) {
case 'and':
- return permissions.every((permission) => !!(value & permission));
+ return permissions.every(
+ (permission) => !!(bigValue & BigInt(permission))
+ );
case 'or':
- return permissions.some((permission) => !!(value & permission));
+ return permissions.some(
+ (permission) => !!(bigValue & BigInt(permission))
+ );
}
} else {
total = permissions;
}
- return !!(value & Permission.ADMIN) || !!(value & total);
+ return (
+ !!(bigValue & BigInt(Permission.ADMIN)) || !!(bigValue & BigInt(total))
+ );
};
diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts
index 08f200dfdf..4d3ce05f0e 100644
--- a/server/lib/scanners/baseScanner.ts
+++ b/server/lib/scanners/baseScanner.ts
@@ -1,7 +1,12 @@
import TheMovieDb from '@server/api/themoviedb';
-import { MediaStatus, MediaType } from '@server/constants/media';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
+import MediaRequest from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -62,6 +67,7 @@ class BaseScanner {
protected scannerName: string;
protected enable4kMovie = false;
protected enable4kShow = false;
+ protected enableAudioBook = false;
protected sessionId: string;
protected running = false;
readonly asyncLock = new AsyncLock();
@@ -257,6 +263,150 @@ class BaseScanner {
});
}
+ protected async processBook(
+ hcId: number,
+ {
+ is4k = false,
+ mediaAddedAt,
+ ratingKey,
+ serviceId,
+ externalServiceId,
+ externalServiceSlug,
+ processing = false,
+ title = 'Unknown Title',
+ hasFile = true,
+ }: ProcessOptions = {}
+ ): Promise {
+ const mediaRepository = getRepository(Media);
+
+ await this.asyncLock.dispatch(hcId, async () => {
+ const existing = await this.getExisting(hcId, MediaType.BOOK);
+
+ if (existing) {
+ let changedExisting = false;
+
+ const statusField = is4k ? 'status4k' : 'status';
+
+ if (existing[statusField] !== MediaStatus.AVAILABLE || !hasFile) {
+ const previousStatus = existing[statusField];
+ const lostFiles =
+ !hasFile &&
+ (previousStatus === MediaStatus.PROCESSING ||
+ previousStatus === MediaStatus.AVAILABLE ||
+ previousStatus === MediaStatus.PARTIALLY_AVAILABLE);
+
+ existing[statusField] =
+ !processing && hasFile
+ ? MediaStatus.AVAILABLE
+ : !processing && lostFiles
+ ? MediaStatus.UNKNOWN
+ : processing
+ ? previousStatus === MediaStatus.DELETED
+ ? MediaStatus.DELETED
+ : MediaStatus.PROCESSING
+ : previousStatus;
+
+ if (existing[statusField] !== previousStatus) {
+ if (mediaAddedAt) {
+ existing.mediaAddedAt = mediaAddedAt;
+ }
+ changedExisting = true;
+ }
+ }
+
+ if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
+ existing.mediaAddedAt = mediaAddedAt;
+ changedExisting = true;
+ }
+
+ if (
+ ratingKey &&
+ existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
+ ) {
+ existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
+ changedExisting = true;
+ }
+
+ if (
+ serviceId !== undefined &&
+ existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
+ ) {
+ existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
+ changedExisting = true;
+ }
+
+ if (
+ externalServiceId !== undefined &&
+ existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
+ externalServiceId
+ ) {
+ existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
+ externalServiceId;
+ changedExisting = true;
+ }
+
+ if (
+ externalServiceSlug !== undefined &&
+ existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
+ externalServiceSlug
+ ) {
+ existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
+ externalServiceSlug;
+ changedExisting = true;
+ }
+
+ if (changedExisting) {
+ await mediaRepository.save(existing);
+ this.log(
+ `Media for ${title} exists. Changes were detected and the title will be updated.`,
+ 'info'
+ );
+ } else {
+ this.log(`Title already exists and no changes detected for ${title}`);
+ }
+ } else {
+ if (!processing && !hasFile) {
+ return;
+ }
+
+ const newMedia = new Media();
+ newMedia.tmdbId = hcId;
+
+ newMedia.status =
+ !is4k && !processing
+ ? MediaStatus.AVAILABLE
+ : !is4k && processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN;
+ newMedia.status4k =
+ is4k && this.enableAudioBook && !processing
+ ? MediaStatus.AVAILABLE
+ : is4k && this.enableAudioBook && processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN;
+ newMedia.mediaType = MediaType.BOOK;
+ newMedia.serviceId = !is4k ? serviceId : undefined;
+ newMedia.serviceId4k = is4k ? serviceId : undefined;
+ newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
+ newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
+ newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
+ newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
+
+ if (mediaAddedAt) {
+ newMedia.mediaAddedAt = mediaAddedAt;
+ }
+
+ if (ratingKey) {
+ newMedia.ratingKey = !is4k ? ratingKey : undefined;
+ newMedia.ratingKey4k =
+ is4k && this.enableAudioBook ? ratingKey : undefined;
+ }
+ await mediaRepository.save(newMedia);
+ this.log(`Saved new media: ${title}`);
+ }
+ });
+ }
+
/**
* processShow takes a TMDB ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
@@ -301,7 +451,14 @@ class BaseScanner {
) ?? []
).length;
- for (const season of seasons) {
+ for (const rawSeason of seasons) {
+ // Files can't promote a season the provider reports as empty.
+ // Zeroing the counts leaves any existing status for availabilitySync to judge.
+ const season =
+ rawSeason.totalEpisodes > 0
+ ? rawSeason
+ : { ...rawSeason, episodes: 0, episodes4k: 0 };
+
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
@@ -467,24 +624,38 @@ class BaseScanner {
(s) => s.seasonNumber !== 0
);
- // Check the actual season objects instead scanner input
- // to determine overall availability status
- // UNKNOWN seasons are treated as neutral (no signal) rather than
- // blockers, so a stale/orphan placeholder season can't hold the
- // show at PARTIALLY_AVAILABLE indefinitely.
+ // DB-only seasons block the rollup unless UNKNOWN (orphan placeholders
+ // can never be revisited by a scan and would pin the show forever).
+ const countsTowardsRollup = (
+ s: Season,
+ statusKey: 'status' | 'status4k'
+ ): boolean => {
+ const scannedSeason = seasons.find(
+ (season) => season.seasonNumber === s.seasonNumber
+ );
+
+ if (scannedSeason) {
+ return scannedSeason.totalEpisodes > 0;
+ }
+
+ return s[statusKey] !== MediaStatus.UNKNOWN;
+ };
+
+ const standardSeasonsForRollup = nonSpecialSeasons.filter((s) =>
+ countsTowardsRollup(s, 'status')
+ );
const isAllStandardSeasonsAvailable =
- nonSpecialSeasons.length > 0 &&
- nonSpecialSeasons
- .filter((s) => s.status !== MediaStatus.UNKNOWN)
- .every((s) => s.status === MediaStatus.AVAILABLE) &&
- nonSpecialSeasons.some((s) => s.status === MediaStatus.AVAILABLE);
+ standardSeasonsForRollup.length > 0 &&
+ standardSeasonsForRollup.every(
+ (s) => s.status === MediaStatus.AVAILABLE
+ );
+ const seasons4kForRollup = nonSpecialSeasons.filter((s) =>
+ countsTowardsRollup(s, 'status4k')
+ );
const isAll4kSeasonsAvailable =
- nonSpecialSeasons.length > 0 &&
- nonSpecialSeasons
- .filter((s) => s.status4k !== MediaStatus.UNKNOWN)
- .every((s) => s.status4k === MediaStatus.AVAILABLE) &&
- nonSpecialSeasons.some((s) => s.status4k === MediaStatus.AVAILABLE);
+ seasons4kForRollup.length > 0 &&
+ seasons4kForRollup.every((s) => s.status4k === MediaStatus.AVAILABLE);
media.status = isAllStandardSeasonsAvailable
? MediaStatus.AVAILABLE
@@ -529,19 +700,18 @@ class BaseScanner {
(s) => s.seasonNumber !== 0
);
+ const newSeasonsForRollup = nonSpecialNewSeasons.filter(
+ (s) =>
+ (seasons.find((season) => season.seasonNumber === s.seasonNumber)
+ ?.totalEpisodes ?? 0) > 0
+ );
const isAllStandardSeasonsAvailable =
- nonSpecialNewSeasons.length > 0 &&
- nonSpecialNewSeasons
- .filter((s) => s.status !== MediaStatus.UNKNOWN)
- .every((s) => s.status === MediaStatus.AVAILABLE) &&
- nonSpecialNewSeasons.some((s) => s.status === MediaStatus.AVAILABLE);
+ newSeasonsForRollup.length > 0 &&
+ newSeasonsForRollup.every((s) => s.status === MediaStatus.AVAILABLE);
const isAll4kSeasonsAvailable =
- nonSpecialNewSeasons.length > 0 &&
- nonSpecialNewSeasons
- .filter((s) => s.status4k !== MediaStatus.UNKNOWN)
- .every((s) => s.status4k === MediaStatus.AVAILABLE) &&
- nonSpecialNewSeasons.some(
+ newSeasonsForRollup.length > 0 &&
+ newSeasonsForRollup.every(
(s) => s.status4k === MediaStatus.AVAILABLE
);
@@ -624,6 +794,43 @@ class BaseScanner {
});
}
+ /**
+ * Declines APPROVED requests bound to media that has been orphaned before completion.
+ * DECLINED clears the duplicate-request guard so the user can re-request it.
+ * Callers must load the requests relation on the media.
+ */
+ protected async declineOrphanedRequests(
+ media: Media,
+ is4k: boolean
+ ): Promise {
+ if (media.requests === undefined) {
+ throw new Error(
+ `declineOrphanedRequests called for media ${media.id} without the 'requests' relation loaded`
+ );
+ }
+
+ const requestRepository = getRepository(MediaRequest);
+
+ const orphanedRequests = (media.requests ?? []).filter(
+ (request) =>
+ request.is4k === is4k && request.status === MediaRequestStatus.APPROVED
+ );
+
+ for (const request of orphanedRequests) {
+ request.status = MediaRequestStatus.DECLINED;
+ // Ensure that the media relation is set so the AfterUpdate
+ // notification hook can resolve it
+ request.media = media;
+ await requestRepository.save(request);
+ this.log(
+ `Declined orphaned ${
+ media.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request ${request.id} for ${media.tmdbId} not found in any Sonarr/Radarr server.`,
+ 'info'
+ );
+ }
+ }
+
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set
@@ -653,6 +860,14 @@ class BaseScanner {
);
}
+ this.enableAudioBook = settings.readarr.some((readarr) => readarr.is4k);
+ if (this.enableAudioBook) {
+ this.log(
+ 'At least one Audiobook Readarr server was detected. Audiobook detection is now enabled',
+ 'info'
+ );
+ }
+
this.running = true;
return sessionId;
diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts
index 7d6a3e5ad1..a3e2300193 100644
--- a/server/lib/scanners/jellyfin/index.ts
+++ b/server/lib/scanners/jellyfin/index.ts
@@ -10,6 +10,7 @@ import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
+ TmdbTvScanDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
@@ -73,10 +74,9 @@ class JellyfinScanner
}
if (imdbId && !tmdbId) {
- const tmdbMovie = await this.tmdb.getMediaByImdbId({
+ tmdbId = await this.tmdb.resolveImdbIdForScan({
imdbId: imdbId,
});
- tmdbId = tmdbMovie.id;
}
if (!tmdbId) {
@@ -184,15 +184,15 @@ class JellyfinScanner
}: {
tmdbId?: number;
tvdbId?: number;
- }): Promise {
+ }): Promise {
let tvShow;
if (tmdbId) {
- tvShow = await this.tmdb.getTvShow({
+ tvShow = await this.tmdb.getTvShowForScan({
tvId: Number(tmdbId),
});
} else if (tvdbId) {
- tvShow = await this.tmdb.getShowByTvdbId({
+ tvShow = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: Number(tvdbId),
});
} else {
@@ -215,7 +215,7 @@ class JellyfinScanner
}
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
- let tvShow: TmdbTvDetails | null = null;
+ let tvShow: TmdbTvScanDetails | TmdbTvDetails | null = null;
try {
const Id =
@@ -262,7 +262,7 @@ class JellyfinScanner
tvdbSeasonFromAnidb = result?.tvdbSeason;
if (result?.tvdbId) {
try {
- tvShow = await this.tmdb.getShowByTvdbId({
+ tvShow = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: result.tvdbId,
});
} catch {
@@ -285,11 +285,9 @@ class JellyfinScanner
const processableSeasons: ProcessableSeason[] = [];
const settings = getSettings();
- const filteredSeasons = (
- settings.main.enableSpecialEpisodes
- ? seasons
- : seasons.filter((sn) => sn.season_number !== 0)
- ).filter((sn) => sn.episode_count > 0);
+ const filteredSeasons = settings.main.enableSpecialEpisodes
+ ? seasons
+ : seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
diff --git a/server/lib/scanners/jellyfin/jellyfin.test.ts b/server/lib/scanners/jellyfin/jellyfin.test.ts
new file mode 100644
index 0000000000..8bc0ff6452
--- /dev/null
+++ b/server/lib/scanners/jellyfin/jellyfin.test.ts
@@ -0,0 +1,524 @@
+import animeList from '@server/api/animelist';
+import type {
+ JellyfinLibraryItem,
+ JellyfinLibraryItemExtended,
+} from '@server/api/jellyfin';
+import JellyfinAPI from '@server/api/jellyfin';
+import TheMovieDb from '@server/api/themoviedb';
+import type {
+ TmdbTvDetails,
+ TmdbTvSeasonResult,
+} from '@server/api/themoviedb/interfaces';
+import { MediaStatus, MediaType } from '@server/constants/media';
+import { MediaServerType } from '@server/constants/server';
+import { getRepository } from '@server/datasource';
+import Media from '@server/entity/Media';
+import Season from '@server/entity/Season';
+import { User } from '@server/entity/User';
+import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
+import type { Library } from '@server/lib/settings';
+import { getSettings } from '@server/lib/settings';
+import { setupTestDb } from '@server/test/db';
+import { runWithMockTimers } from '@server/test/runWithMockTimers';
+import assert from 'node:assert/strict';
+import { beforeEach, describe, it } from 'node:test';
+
+// --- Mock animeList.sync to avoid filesystem/network I/O in tests ---
+Object.defineProperty(animeList, 'sync', {
+ value: async () => {},
+ configurable: true,
+ writable: true,
+});
+
+// --- Mock JellyfinAPI ---
+let getLibraryContentsImpl: (
+ id: string
+) => Promise = async () => [];
+let getItemDataImpl: (
+ id: string
+) => Promise = async () => undefined;
+let getSeasonsImpl: (
+ seriesID: string
+) => Promise = async () => [];
+let getEpisodesImpl: (
+ seriesID: string,
+ seasonID: string
+) => Promise = async () => [];
+
+Object.defineProperty(JellyfinAPI.prototype, 'getLibraryContents', {
+ get() {
+ return async (id: string) => getLibraryContentsImpl(id);
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(JellyfinAPI.prototype, 'getItemData', {
+ get() {
+ return async (id: string) => getItemDataImpl(id);
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(JellyfinAPI.prototype, 'getSeasons', {
+ get() {
+ return async (seriesID: string) => getSeasonsImpl(seriesID);
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(JellyfinAPI.prototype, 'getEpisodes', {
+ get() {
+ return async (seriesID: string, seasonID: string) =>
+ getEpisodesImpl(seriesID, seasonID);
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(JellyfinAPI.prototype, 'setUserId', {
+ get() {
+ return () => {};
+ },
+ set() {},
+ configurable: true,
+});
+
+// --- Mock TheMovieDb ---
+let getTvShowImpl: (args: {
+ tvId: number;
+ language?: string;
+}) => Promise = async () => fakeTmdbShow(1);
+
+Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
+ get() {
+ return async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args);
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(TheMovieDb.prototype, 'getTvShowForScan', {
+ get() {
+ return async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args);
+ },
+ set() {},
+ configurable: true,
+});
+
+// both are assigned in the constructor, so the prototype stubs miss the
+// instance jellyfinFullScanner built when it was first imported
+for (const method of ['getTvShow', 'getTvShowForScan'] as const) {
+ Object.defineProperty(jellyfinFullScanner.tmdb, method, {
+ value: async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args),
+ configurable: true,
+ });
+}
+
+setupTestDb();
+
+// --- Helpers ---
+
+function fakeTmdbShow(
+ tmdbId: number,
+ seasons: TmdbTvSeasonResult[] = [
+ {
+ id: 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 1',
+ overview: '',
+ season_number: 1,
+ },
+ ]
+): TmdbTvDetails {
+ return {
+ id: tmdbId,
+ content_ratings: { results: [] },
+ created_by: [],
+ episode_run_time: [],
+ first_air_date: '2024-01-01',
+ genres: [],
+ homepage: '',
+ in_production: false,
+ languages: ['en'],
+ last_air_date: '2024-01-01',
+ name: 'Test Show',
+ networks: [],
+ number_of_episodes: 10,
+ number_of_seasons: seasons.length,
+ origin_country: ['US'],
+ original_language: 'en',
+ original_name: 'Test Show',
+ overview: '',
+ popularity: 0,
+ production_companies: [],
+ production_countries: [],
+ spoken_languages: [],
+ seasons,
+ status: 'Ended',
+ type: 'Scripted',
+ vote_average: 0,
+ vote_count: 0,
+ aggregate_credits: { cast: [] },
+ credits: { crew: [] },
+ external_ids: {},
+ keywords: { results: [] },
+ videos: { results: [] },
+ };
+}
+
+function fakeJellyfinSeriesItem(id: string): JellyfinLibraryItem {
+ return {
+ Name: 'Test Show',
+ Id: id,
+ Type: 'Series',
+ HasSubtitles: false,
+ LocationType: 'FileSystem',
+ MediaType: 'Video',
+ };
+}
+
+function fakeJellyfinShowMetadata(
+ id: string,
+ tmdbId: string
+): JellyfinLibraryItemExtended {
+ return {
+ Name: 'Test Show',
+ Id: id,
+ Type: 'Series',
+ HasSubtitles: false,
+ LocationType: 'FileSystem',
+ MediaType: 'Video',
+ ProviderIds: { Tmdb: tmdbId },
+ };
+}
+
+function fakeJellyfinSeason(
+ seasonNumber: number,
+ id: string
+): JellyfinLibraryItem {
+ return {
+ Name: `Season ${seasonNumber}`,
+ Id: id,
+ IndexNumber: seasonNumber,
+ Type: 'Season',
+ HasSubtitles: false,
+ LocationType: 'FileSystem',
+ MediaType: 'Video',
+ };
+}
+
+function fakeJellyfinEpisodes(count: number): JellyfinLibraryItem[] {
+ return Array.from({ length: count }, (_, i) => ({
+ Name: `Episode ${i + 1}`,
+ Id: `ep-${i}`,
+ IndexNumber: i + 1,
+ Type: 'Episode' as const,
+ HasSubtitles: false,
+ LocationType: 'FileSystem' as const,
+ MediaType: 'Video',
+ }));
+}
+
+function configureJellyfinWithLibrary(
+ libraries: Library[] = [
+ { id: 'test-library-id', name: 'TV Shows', enabled: true, type: 'show' },
+ ]
+): void {
+ const settings = getSettings();
+ settings.main.mediaServerType = MediaServerType.JELLYFIN;
+ settings.jellyfin = {
+ ...settings.jellyfin,
+ apiKey: 'test-api-key',
+ libraries,
+ };
+}
+
+describe('Jellyfin Scanner', () => {
+ beforeEach(async () => {
+ getLibraryContentsImpl = async () => [];
+ getItemDataImpl = async () => undefined;
+ getSeasonsImpl = async () => [];
+ getEpisodesImpl = async () => [];
+ getTvShowImpl = async () => fakeTmdbShow(1);
+
+ const userRepository = getRepository(User);
+ const existingAdmin = await userRepository.findOne({ where: { id: 1 } });
+ if (!existingAdmin) {
+ const admin = new User();
+ admin.id = 1;
+ admin.jellyfinUserId = 'admin-user-id';
+ admin.jellyfinDeviceId = 'admin-device-id';
+ admin.email = 'admin@test.com';
+ admin.permissions = 2;
+ admin.username = 'admin';
+ await userRepository.save(admin);
+ }
+ });
+
+ describe('empty TMDB season handling', () => {
+ it('should mark show as available when all non-empty TMDB seasons are fully scanned and an empty placeholder season exists in the DB', async () => {
+ configureJellyfinWithLibrary();
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 5000;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.PARTIALLY_AVAILABLE;
+ media.jellyfinMediaId = 'jf-scanner-show-id';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 3,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getTvShowImpl = async () =>
+ fakeTmdbShow(5000, [
+ {
+ id: 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 1',
+ overview: '',
+ season_number: 1,
+ },
+ {
+ id: 2,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 2',
+ overview: '',
+ season_number: 2,
+ },
+ {
+ id: 3,
+ air_date: '2024-01-01',
+ episode_count: 0,
+ name: 'Season 3',
+ overview: '',
+ season_number: 3,
+ },
+ ]);
+
+ // Jellyfin: S1 and S2 are fully scanned; S3 has no files.
+ getLibraryContentsImpl = async (id: string) => {
+ if (id === 'test-library-id') {
+ return [fakeJellyfinSeriesItem('jf-scanner-show-id')];
+ }
+ return [];
+ };
+
+ getItemDataImpl = async (id: string) => {
+ if (id === 'jf-scanner-show-id') {
+ return fakeJellyfinShowMetadata('jf-scanner-show-id', '5000');
+ }
+ return undefined;
+ };
+
+ getSeasonsImpl = async (seriesID: string) => {
+ if (seriesID === 'jf-scanner-show-id') {
+ return [
+ fakeJellyfinSeason(1, 'jf-scanner-s1-id'),
+ fakeJellyfinSeason(2, 'jf-scanner-s2-id'),
+ ];
+ }
+ return [];
+ };
+
+ getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
+ if (seasonID === 'jf-scanner-s1-id') return fakeJellyfinEpisodes(10);
+ if (seasonID === 'jf-scanner-s2-id') return fakeJellyfinEpisodes(10);
+ return [];
+ };
+
+ await runWithMockTimers(() => jellyfinFullScanner.run());
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 5000 },
+ relations: ['seasons'],
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'Show should be AVAILABLE when all non-empty TMDB seasons are fully scanned, ignoring empty placeholder seasons'
+ );
+ });
+
+ it('should mark show as available when an orphan UNKNOWN season exists in the DB but not in TMDB', async () => {
+ configureJellyfinWithLibrary();
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 5001;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.PARTIALLY_AVAILABLE;
+ media.jellyfinMediaId = 'jf-orphan-show-id';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ // Not present in the TMDB season list below
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getTvShowImpl = async () =>
+ fakeTmdbShow(5001, [
+ {
+ id: 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 1',
+ overview: '',
+ season_number: 1,
+ },
+ ]);
+
+ getLibraryContentsImpl = async (id: string) => {
+ if (id === 'test-library-id') {
+ return [fakeJellyfinSeriesItem('jf-orphan-show-id')];
+ }
+ return [];
+ };
+
+ getItemDataImpl = async (id: string) => {
+ if (id === 'jf-orphan-show-id') {
+ return fakeJellyfinShowMetadata('jf-orphan-show-id', '5001');
+ }
+ return undefined;
+ };
+
+ getSeasonsImpl = async (seriesID: string) => {
+ if (seriesID === 'jf-orphan-show-id') {
+ return [fakeJellyfinSeason(1, 'jf-orphan-s1-id')];
+ }
+ return [];
+ };
+
+ getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
+ if (seasonID === 'jf-orphan-s1-id') return fakeJellyfinEpisodes(10);
+ return [];
+ };
+
+ await runWithMockTimers(() => jellyfinFullScanner.run());
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 5001 },
+ relations: ['seasons'],
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.AVAILABLE,
+ 'Show should be AVAILABLE when the only DB season missing from TMDB is an UNKNOWN orphan placeholder'
+ );
+ });
+
+ it('should keep show partially available when a season missing from TMDB was previously available', async () => {
+ configureJellyfinWithLibrary();
+
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 5002;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.PARTIALLY_AVAILABLE;
+ media.jellyfinMediaId = 'jf-deleted-show-id';
+ media.seasons = [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ new Season({
+ seasonNumber: 2,
+ status: MediaStatus.DELETED,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ];
+
+ await mediaRepository.save(media);
+
+ getTvShowImpl = async () =>
+ fakeTmdbShow(5002, [
+ {
+ id: 1,
+ air_date: '2024-01-01',
+ episode_count: 10,
+ name: 'Season 1',
+ overview: '',
+ season_number: 1,
+ },
+ ]);
+
+ getLibraryContentsImpl = async (id: string) => {
+ if (id === 'test-library-id') {
+ return [fakeJellyfinSeriesItem('jf-deleted-show-id')];
+ }
+ return [];
+ };
+
+ getItemDataImpl = async (id: string) => {
+ if (id === 'jf-deleted-show-id') {
+ return fakeJellyfinShowMetadata('jf-deleted-show-id', '5002');
+ }
+ return undefined;
+ };
+
+ getSeasonsImpl = async (seriesID: string) => {
+ if (seriesID === 'jf-deleted-show-id') {
+ return [fakeJellyfinSeason(1, 'jf-deleted-s1-id')];
+ }
+ return [];
+ };
+
+ getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
+ if (seasonID === 'jf-deleted-s1-id') return fakeJellyfinEpisodes(10);
+ return [];
+ };
+
+ await runWithMockTimers(() => jellyfinFullScanner.run());
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 5002 },
+ relations: ['seasons'],
+ });
+
+ assert.strictEqual(
+ updated.status,
+ MediaStatus.PARTIALLY_AVAILABLE,
+ 'Show should stay PARTIALLY_AVAILABLE when a DELETED season is missing from the metadata provider'
+ );
+ });
+ });
+});
diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts
index 0ea05ac1a8..f3eda4a3bc 100644
--- a/server/lib/scanners/plex/index.ts
+++ b/server/lib/scanners/plex/index.ts
@@ -7,10 +7,10 @@ import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
+ TmdbTvScanDetails,
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
-import cacheManager from '@server/lib/cache';
import type {
MediaIds,
ProcessableSeason,
@@ -27,6 +27,9 @@ const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
+const plexCustomProviderRegex = new RegExp(
+ /tv\.plex\.agents\.custom(\.[a-zA-Z0-9]+)+:\/\//
+);
// Hama agent uses ASS naming, see details here:
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
@@ -261,15 +264,15 @@ class PlexScanner
}: {
tmdbId?: number;
tvdbId?: number;
- }): Promise {
+ }): Promise {
let tvShow;
if (tmdbId) {
- tvShow = await this.tmdb.getTvShow({
+ tvShow = await this.tmdb.getTvShowForScan({
tvId: Number(tmdbId),
});
} else if (tvdbId) {
- tvShow = await this.tmdb.getShowByTvdbId({
+ tvShow = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: Number(tvdbId),
});
} else {
@@ -321,13 +324,11 @@ class PlexScanner
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
- const settings = getSettings();
- const filteredSeasons = (
- settings.main.enableSpecialEpisodes
- ? seasons
- : seasons.filter((sn) => sn.season_number !== 0)
- ).filter((sn) => sn.episode_count > 0);
+ const settings = getSettings();
+ const filteredSeasons = settings.main.enableSpecialEpisodes
+ ? seasons
+ : seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
@@ -382,21 +383,12 @@ class PlexScanner
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise {
- let mediaIds: Partial = {};
+ const mediaIds: Partial = {};
// Check if item is using new plex movie/tv agent
- if (plexitem.guid.match(plexRegex)) {
- const guidCache = cacheManager.getCache('plexguid');
-
- const cachedGuids = guidCache.data.get(plexitem.ratingKey);
-
- if (cachedGuids) {
- this.log('GUIDs are cached. Skipping metadata request.', 'debug', {
- mediaIds: cachedGuids,
- title: plexitem.title,
- });
- mediaIds = cachedGuids;
- }
-
+ if (
+ plexitem.guid.match(plexRegex) ||
+ plexitem.guid.match(plexCustomProviderRegex)
+ ) {
const metadata =
plexitem.Guid && plexitem.Guid.length > 0
? plexitem
@@ -424,31 +416,26 @@ class PlexScanner
// If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
- const tmdbMedia = await this.tmdb.getMediaByImdbId({
+ mediaIds.tmdbId = await this.tmdb.resolveImdbIdForScan({
imdbId: mediaIds.imdbId,
});
- mediaIds.tmdbId = tmdbMedia.id;
}
if (mediaIds.tvdbId && !mediaIds.tmdbId) {
- const show = await this.tmdb.getShowByTvdbId({
+ const show = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: mediaIds.tvdbId,
});
mediaIds.tmdbId = show.id;
}
- // Cache GUIDs
- guidCache.data.set(plexitem.ratingKey, mediaIds);
-
// Check if the agent is IMDb
} else if (plexitem.guid.match(imdbRegex)) {
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
- const tmdbMedia = await this.tmdb.getMediaByImdbId({
+ mediaIds.tmdbId = await this.tmdb.resolveImdbIdForScan({
imdbId: mediaIds.imdbId,
});
- mediaIds.tmdbId = tmdbMedia.id;
}
// Check if the agent is TMDB
} else if (plexitem.guid.match(tmdbRegex)) {
@@ -460,9 +447,9 @@ class PlexScanner
} else if (plexitem.guid.match(tvdbRegex)) {
const matchedtvdb = plexitem.guid.match(tvdbRegex);
- // If we can find a tvdb Id, use it to get the full tmdb show details
+ // If we can find a tvdb Id, use it to resolve the tmdb id
if (matchedtvdb) {
- const show = await this.tmdb.getShowByTvdbId({
+ const show = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: Number(matchedtvdb[1]),
});
@@ -480,7 +467,7 @@ class PlexScanner
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
if (matchedtvdb) {
- const show = await this.tmdb.getShowByTvdbId({
+ const show = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: Number(matchedtvdb[1]),
});
@@ -502,19 +489,19 @@ class PlexScanner
} else if (matchedhama) {
const anidbId = Number(matchedhama[1]);
const result = animeList.getFromAnidbId(anidbId);
- let tvShow: TmdbTvDetails | null = null;
+ let tvShow: TmdbTvScanDetails | TmdbTvDetails | null = null;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
// First try to lookup the show by TVDb ID
if (result?.tvdbId) {
- const extResponse = await this.tmdb.getByExternalId({
+ const extResponse = await this.tmdb.getByExternalIdForScan({
externalId: result.tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
- tvShow = await this.tmdb.getTvShow({
+ tvShow = await this.tmdb.getTvShowForScan({
tvId: extResponse.tv_results[0].id,
});
mediaIds.tvdbId = result.tvdbId;
@@ -533,10 +520,9 @@ class PlexScanner
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
- const tmdbMovie = await this.tmdb.getMediaByImdbId({
+ mediaIds.tmdbId = await this.tmdb.resolveImdbIdForScan({
imdbId: result.imdbId,
});
- mediaIds.tmdbId = tmdbMovie.id;
mediaIds.imdbId = result.imdbId;
}
}
@@ -582,10 +568,10 @@ class PlexScanner
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
- const tmdbMovie = await this.tmdb.getMediaByImdbId({
+ const tmdbId = await this.tmdb.resolveImdbIdForScan({
imdbId: special.imdbId,
});
- await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
+ await this.processPlexMovieByTmdbId(episode, tmdbId);
}
}
}
diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts
index 98643dfd49..bde08d65d6 100644
--- a/server/lib/scanners/radarr/index.ts
+++ b/server/lib/scanners/radarr/index.ts
@@ -28,6 +28,8 @@ class RadarrScanner
private scanned4kTmdbIds: Set = new Set();
private didScanStandard = false;
private didScan4k = false;
+ private serverReturnedEmpty = false;
+ private server4kReturnedEmpty = false;
constructor() {
super('Radarr Scan', { bundleSize: 50 });
@@ -50,6 +52,8 @@ class RadarrScanner
this.scanned4kTmdbIds.clear();
this.didScanStandard = false;
this.didScan4k = false;
+ this.serverReturnedEmpty = false;
+ this.server4kReturnedEmpty = false;
try {
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
@@ -82,6 +86,18 @@ class RadarrScanner
this.didScanStandard = true;
}
+ if (this.items.length === 0) {
+ if (server4k) {
+ this.server4kReturnedEmpty = true;
+ } else {
+ this.serverReturnedEmpty = true;
+ }
+ this.log(
+ `Radarr server ${server.name} returned no movies. Orphan cleanup for this profile type will be skipped.`,
+ 'warn'
+ );
+ }
+
await this.loop(this.processRadarrMovie.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
@@ -106,6 +122,13 @@ class RadarrScanner
this.didScan4k = false;
}
+ if (this.serverReturnedEmpty) {
+ this.didScanStandard = false;
+ }
+ if (this.server4kReturnedEmpty) {
+ this.didScan4k = false;
+ }
+
await this.cleanupOrphanedMovies();
this.log('Radarr scan complete', 'info');
} catch (e) {
@@ -141,18 +164,57 @@ class RadarrScanner
}
}
+ private async existsInAnyServer(
+ tmdbId: number,
+ is4k: boolean
+ ): Promise {
+ const servers = this.servers.filter(
+ (server) =>
+ server.syncEnabled && (this.enable4kMovie && server.is4k) === is4k
+ );
+
+ for (const server of servers) {
+ try {
+ const api = new RadarrAPI({
+ apiKey: server.apiKey,
+ url: RadarrAPI.buildUrl(server, '/api/v3'),
+ });
+ const movies = await api.getLibraryMoviesByTmdbId(tmdbId);
+
+ if (movies.some((movie) => movie.tmdbId === tmdbId)) {
+ return true;
+ }
+ } catch (e) {
+ this.log(
+ `Could not confirm movie ${tmdbId} against Radarr server ${server.name}. Skipping cleanup for it.`,
+ 'warn',
+ { errorMessage: e.message }
+ );
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private async cleanupOrphanedMovies(): Promise {
const mediaRepository = getRepository(Media);
if (this.didScanStandard) {
const processingMovies = await mediaRepository.find({
where: { mediaType: MediaType.MOVIE, status: MediaStatus.PROCESSING },
+ relations: { requests: true },
});
for (const media of processingMovies) {
if (!this.scannedTmdbIds.has(media.tmdbId)) {
+ if (await this.existsInAnyServer(media.tmdbId, false)) {
+ continue;
+ }
+
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
+ await this.declineOrphanedRequests(media, false);
this.log(
`Movie ${media.tmdbId} not found in any Radarr server. Status reset to UNKNOWN.`,
'info'
@@ -172,12 +234,18 @@ class RadarrScanner
mediaType: MediaType.MOVIE,
status4k: MediaStatus.PROCESSING,
},
+ relations: { requests: true },
});
for (const media of processing4kMovies) {
if (!this.scanned4kTmdbIds.has(media.tmdbId)) {
+ if (await this.existsInAnyServer(media.tmdbId, true)) {
+ continue;
+ }
+
media.status4k = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
+ await this.declineOrphanedRequests(media, true);
this.log(
`Movie ${media.tmdbId} not found in any 4K Radarr server. 4K status reset to UNKNOWN.`,
'info'
diff --git a/server/lib/scanners/radarr/radarr.test.ts b/server/lib/scanners/radarr/radarr.test.ts
index 382724e658..f99195a713 100644
--- a/server/lib/scanners/radarr/radarr.test.ts
+++ b/server/lib/scanners/radarr/radarr.test.ts
@@ -1,14 +1,21 @@
-import assert from 'node:assert/strict';
-import { beforeEach, describe, it } from 'node:test';
-
import type { RadarrMovie } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
-import { MediaStatus, MediaType } from '@server/constants/media';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
+import MediaRequest from '@server/entity/MediaRequest';
+import { User } from '@server/entity/User';
+import { radarrScanner } from '@server/lib/scanners/radarr';
import type { RadarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { setupTestDb } from '@server/test/db';
+import { runWithMockTimers } from '@server/test/runWithMockTimers';
+import assert from 'node:assert/strict';
+import { beforeEach, describe, it, mock } from 'node:test';
let getMoviesImpl: () => Promise = async () => [];
Object.defineProperty(RadarrAPI.prototype, 'getMovies', {
@@ -19,7 +26,18 @@ Object.defineProperty(RadarrAPI.prototype, 'getMovies', {
configurable: true,
});
-import { radarrScanner } from '@server/lib/scanners/radarr';
+let getLibraryMoviesByTmdbIdImpl: (
+ tmdbId: number
+) => Promise = async () => [];
+Object.defineProperty(RadarrAPI.prototype, 'getLibraryMoviesByTmdbId', {
+ set() {},
+ get() {
+ return async (tmdbId: number) => getLibraryMoviesByTmdbIdImpl(tmdbId);
+ },
+ configurable: true,
+});
+
+mock.method(MediaRequest, 'sendNotification', async () => undefined);
setupTestDb();
@@ -70,6 +88,7 @@ function fakeRadarrMovie(overrides: Partial = {}): RadarrMovie {
describe('Radarr Scanner', () => {
beforeEach(() => {
getMoviesImpl = async () => [];
+ getLibraryMoviesByTmdbIdImpl = async () => [];
});
describe('unmonitored movie handling', () => {
@@ -87,7 +106,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ monitored: false, hasFile: false }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 550 },
@@ -103,7 +122,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ tmdbId: 777, monitored: false, hasFile: false }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const media = await mediaRepository.findOne({
where: { tmdbId: 777 },
@@ -125,7 +144,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ tmdbId: 551, monitored: true, hasFile: true }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 551 },
@@ -147,7 +166,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ tmdbId: 552, monitored: true, hasFile: false }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 552 },
@@ -169,7 +188,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ tmdbId: 553, monitored: true, hasFile: false }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 553 },
@@ -191,7 +210,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ tmdbId: 554, monitored: false, hasFile: true }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 554 },
@@ -217,7 +236,7 @@ describe('Radarr Scanner', () => {
getMoviesImpl = async () => [];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 950 },
@@ -235,10 +254,9 @@ describe('Radarr Scanner', () => {
await mediaRepository.save(media);
configureRadarr([{ syncEnabled: true }]);
- // Radarr returns empty meaning movie was deleted
- getMoviesImpl = async () => [];
+ getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 1, id: 99 })];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 999 },
@@ -258,7 +276,7 @@ describe('Radarr Scanner', () => {
configureRadarr([{ syncEnabled: true }]);
getMoviesImpl = async () => [];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 888 },
@@ -280,7 +298,7 @@ describe('Radarr Scanner', () => {
fakeRadarrMovie({ tmdbId: 700, monitored: true, hasFile: false }),
];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 700 },
@@ -301,7 +319,7 @@ describe('Radarr Scanner', () => {
configureRadarr([{ syncEnabled: true }]);
getMoviesImpl = async () => [];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 800 },
@@ -336,10 +354,10 @@ describe('Radarr Scanner', () => {
if (callCount === 1) {
return [fakeRadarrMovie({ tmdbId: 902, id: 10 })];
}
- return [];
+ return [fakeRadarrMovie({ tmdbId: 903, id: 11 })];
};
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updatedOrphan = await mediaRepository.findOneOrFail({
where: { tmdbId: 901 },
@@ -351,6 +369,76 @@ describe('Radarr Scanner', () => {
});
assert.strictEqual(updatedExisting.status, MediaStatus.AVAILABLE);
});
+
+ it('does not reset a movie added to Radarr after the scan started', async () => {
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 910;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.PROCESSING;
+ await mediaRepository.save(media);
+
+ configureRadarr([{ syncEnabled: true }]);
+ getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 111 })];
+ getLibraryMoviesByTmdbIdImpl = async (tmdbId) => [
+ fakeRadarrMovie({ tmdbId }),
+ ];
+
+ await radarrScanner.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 910 },
+ });
+ assert.strictEqual(updated.status, MediaStatus.PROCESSING);
+ });
+
+ it('does not reset a movie when the server cannot be reached', async () => {
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 911;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.PROCESSING;
+ await mediaRepository.save(media);
+
+ configureRadarr([{ syncEnabled: true }]);
+ getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 111 })];
+ getLibraryMoviesByTmdbIdImpl = async () => {
+ throw new Error('connect ECONNREFUSED');
+ };
+
+ await radarrScanner.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 911 },
+ });
+ assert.strictEqual(updated.status, MediaStatus.PROCESSING);
+ });
+
+ it('resets a movie when the server returns no row matching its id', async () => {
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 912;
+ media.mediaType = MediaType.MOVIE;
+ media.status = MediaStatus.PROCESSING;
+ await mediaRepository.save(media);
+
+ configureRadarr([{ syncEnabled: true }]);
+ getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 111 })];
+ getLibraryMoviesByTmdbIdImpl = async () => [
+ fakeRadarrMovie({ tmdbId: 111 }),
+ fakeRadarrMovie({ tmdbId: 222 }),
+ ];
+
+ await radarrScanner.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 912 },
+ });
+ assert.strictEqual(updated.status, MediaStatus.UNKNOWN);
+ });
});
describe('4k orphaned movie cleanup', () => {
@@ -365,9 +453,9 @@ describe('Radarr Scanner', () => {
await mediaRepository.save(media);
configureRadarr([{ syncEnabled: true, is4k: true }]);
- getMoviesImpl = async () => [];
+ getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 1, id: 99 })];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 960 },
@@ -388,7 +476,7 @@ describe('Radarr Scanner', () => {
configureRadarr([{ syncEnabled: true, is4k: true }]);
getMoviesImpl = async () => [];
- await radarrScanner.run();
+ await runWithMockTimers(() => radarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 961 },
@@ -396,4 +484,242 @@ describe('Radarr Scanner', () => {
assert.strictEqual(updated.status4k, MediaStatus.AVAILABLE);
});
});
+
+ describe('orphaned request handling', () => {
+ it('declines the approved request and resets media to UNKNOWN when the movie is orphaned', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 1003596,
+ mediaType: MediaType.MOVIE,
+ status: MediaStatus.PROCESSING,
+ })
+ );
+
+ const settings = getSettings();
+ settings.radarr = [];
+ settings.sonarr = [];
+ const request = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+
+ configureRadarr([{ syncEnabled: true }]);
+ getMoviesImpl = async () => [fakeRadarrMovie({ tmdbId: 1, id: 99 })];
+
+ await runWithMockTimers(() => radarrScanner.run());
+
+ const updatedMedia = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1003596 },
+ });
+ const updatedRequest = await requestRepository.findOneOrFail({
+ where: { id: request.id },
+ });
+
+ assert.strictEqual(updatedMedia.status, MediaStatus.UNKNOWN);
+ assert.strictEqual(updatedRequest.status, MediaRequestStatus.DECLINED);
+ });
+
+ it('does not decline the request when the movie still exists in Radarr', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 700,
+ mediaType: MediaType.MOVIE,
+ status: MediaStatus.PROCESSING,
+ })
+ );
+
+ const settings = getSettings();
+ settings.radarr = [];
+ settings.sonarr = [];
+ const request = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+
+ configureRadarr([{ syncEnabled: true }]);
+ getMoviesImpl = async () => [
+ fakeRadarrMovie({ tmdbId: 700, monitored: true, hasFile: false }),
+ ];
+
+ await runWithMockTimers(() => radarrScanner.run());
+
+ const updatedRequest = await requestRepository.findOneOrFail({
+ where: { id: request.id },
+ });
+ assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
+ });
+
+ it('skips cleanup and leaves the request approved when Radarr returns an empty list', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 1234,
+ mediaType: MediaType.MOVIE,
+ status: MediaStatus.PROCESSING,
+ })
+ );
+
+ const settings = getSettings();
+ settings.radarr = [];
+ settings.sonarr = [];
+ const request = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+
+ configureRadarr([{ syncEnabled: true }]);
+ getMoviesImpl = async () => [];
+
+ await runWithMockTimers(() => radarrScanner.run());
+
+ const updatedMedia = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1234 },
+ });
+ const updatedRequest = await requestRepository.findOneOrFail({
+ where: { id: request.id },
+ });
+
+ assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
+ assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
+ });
+
+ it('declineOrphanedRequests throws when the requests relation is not loaded', async () => {
+ const media = new Media();
+ media.id = 1;
+ media.tmdbId = 123;
+ media.mediaType = MediaType.MOVIE;
+
+ await assert.rejects(
+ () =>
+ (
+ radarrScanner as unknown as {
+ declineOrphanedRequests: (
+ m: Media,
+ is4k: boolean
+ ) => Promise;
+ }
+ ).declineOrphanedRequests(media, false),
+ /without the 'requests' relation loaded/
+ );
+ });
+
+ it('declines only the 4k request when the 4k dimension is orphaned but standard still exists', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 1003598,
+ mediaType: MediaType.MOVIE,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.PROCESSING,
+ })
+ );
+
+ const settings = getSettings();
+ settings.radarr = [];
+ settings.sonarr = [];
+ const standardRequest = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+ const fourKRequest = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: true,
+ })
+ );
+
+ configureRadarr([
+ { syncEnabled: true, id: 0, hostname: 'server-standard' },
+ { syncEnabled: true, id: 1, hostname: 'server-4k', is4k: true },
+ ]);
+
+ let callCount = 0;
+ getMoviesImpl = async () => {
+ callCount++;
+ if (callCount === 1) {
+ // standard server still has the movie
+ return [
+ fakeRadarrMovie({
+ tmdbId: 1003598,
+ id: 42,
+ monitored: true,
+ hasFile: false,
+ }),
+ ];
+ }
+ // 4k server: populated but the movie is absent, so 4k dimension orphaned
+ return [fakeRadarrMovie({ tmdbId: 2, id: 88 })];
+ };
+
+ await runWithMockTimers(() => radarrScanner.run());
+
+ const updatedMedia = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1003598 },
+ });
+ const updatedStandard = await requestRepository.findOneOrFail({
+ where: { id: standardRequest.id },
+ });
+ const updated4k = await requestRepository.findOneOrFail({
+ where: { id: fourKRequest.id },
+ });
+
+ assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
+ assert.strictEqual(updatedMedia.status4k, MediaStatus.UNKNOWN);
+ assert.strictEqual(updatedStandard.status, MediaRequestStatus.APPROVED);
+ assert.strictEqual(updated4k.status, MediaRequestStatus.DECLINED);
+ });
+ });
});
diff --git a/server/lib/scanners/readarr/index.ts b/server/lib/scanners/readarr/index.ts
new file mode 100644
index 0000000000..55e9f0d293
--- /dev/null
+++ b/server/lib/scanners/readarr/index.ts
@@ -0,0 +1,352 @@
+import type { ReadarrBook } from '@server/api/servarr/readarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
+import { MediaStatus, MediaType } from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import Media from '@server/entity/Media';
+import type {
+ RunnableScanner,
+ StatusBase,
+} from '@server/lib/scanners/baseScanner';
+import BaseScanner from '@server/lib/scanners/baseScanner';
+import type { ReadarrSettings } from '@server/lib/settings';
+import { getSettings } from '@server/lib/settings';
+import { uniqWith } from 'lodash';
+import { In } from 'typeorm';
+
+type SyncStatus = StatusBase & {
+ currentServer: ReadarrSettings;
+ servers: ReadarrSettings[];
+};
+
+class ReadarrScanner
+ extends BaseScanner
+ implements RunnableScanner
+{
+ private servers: ReadarrSettings[];
+ private currentServer: ReadarrSettings;
+ private readarrApi: ReadarrAPI;
+ private scannedHcIds: Set = new Set();
+ private scannedAudioHcIds: Set = new Set();
+ private didScanStandard = false;
+ private didScanAudio = false;
+ private isRecentOnly = false;
+
+ constructor(isRecentOnly = false) {
+ super('Readarr Scan', { bundleSize: 50 });
+ this.isRecentOnly = isRecentOnly;
+ }
+
+ public status(): SyncStatus {
+ return {
+ running: this.running,
+ progress: this.progress,
+ total: this.items.length,
+ currentServer: this.currentServer,
+ servers: this.servers,
+ };
+ }
+
+ public async run(): Promise {
+ if (this.running) {
+ this.log('Scan already in progress. Skipping.', 'info');
+ return;
+ }
+
+ const settings = getSettings();
+ const sessionId = this.startRun();
+ this.scannedHcIds.clear();
+ this.scannedAudioHcIds.clear();
+ this.didScanStandard = false;
+ this.didScanAudio = false;
+
+ try {
+ this.servers = uniqWith(settings.readarr, (readarrA, readarrB) => {
+ return (
+ readarrA.hostname === readarrB.hostname &&
+ readarrA.port === readarrB.port &&
+ readarrA.baseUrl === readarrB.baseUrl
+ );
+ });
+
+ for (const server of this.servers) {
+ this.currentServer = server;
+ if (server.syncEnabled) {
+ if (this.isRecentOnly) {
+ await this.runRecentScan(server, sessionId);
+ } else {
+ await this.runFullScan(server, sessionId);
+ }
+ } else {
+ this.log(`Sync not enabled. Skipping Readarr server: ${server.name}`);
+ }
+ }
+
+ // Only run cleanup if all servers of this profile type have sync enabled.
+ // If any server is skipped, we can't distinguish truly orphaned media from
+ // media that exists on an unscanned server (e.g. separate instances for
+ // different genres or languages).
+ const allStandardScanned = this.servers
+ .filter((s) => !this.enableAudioBook || !s.is4k)
+ .every((s) => s.syncEnabled);
+ const allAudioScanned = this.servers
+ .filter((s) => this.enableAudioBook && s.is4k)
+ .every((s) => s.syncEnabled);
+
+ if (!allStandardScanned) {
+ this.didScanStandard = false;
+ }
+ if (!allAudioScanned) {
+ this.didScanAudio = false;
+ }
+
+ await this.cleanupOrphanedBooks();
+
+ this.log(
+ this.isRecentOnly
+ ? 'Recently Added Scan Complete'
+ : 'Full Scan Complete',
+ 'info'
+ );
+ } catch (e) {
+ this.log('Scan interrupted', 'error', { errorMessage: e.message });
+ } finally {
+ this.endRun(sessionId);
+ }
+ }
+
+ private async runFullScan(
+ server: ReadarrSettings,
+ sessionId: string
+ ): Promise {
+ this.log(`Beginning to process Readarr server: ${server.name}`, 'info');
+
+ this.readarrApi = new ReadarrAPI({
+ apiKey: server.apiKey,
+ url: ReadarrAPI.buildUrl(server, '/api/v1'),
+ });
+
+ this.items = await this.readarrApi.getBooks();
+
+ const serverAudio = this.enableAudioBook && server.is4k;
+ if (serverAudio) {
+ this.didScanAudio = true;
+ } else {
+ this.didScanStandard = true;
+ }
+
+ await this.loop(this.processReadarrBook.bind(this), { sessionId });
+ }
+
+ private async runRecentScan(
+ server: ReadarrSettings,
+ sessionId: string
+ ): Promise {
+ this.log(
+ `Beginning to process recently added for Readarr server: ${server.name}`,
+ 'info'
+ );
+
+ this.readarrApi = new ReadarrAPI({
+ apiKey: server.apiKey,
+ url: ReadarrAPI.buildUrl(server, '/api/v1'),
+ });
+
+ const allBooks = await this.readarrApi.getBooks();
+
+ // Compare Readarr's state against what Seerr already has in its database.
+ // We skip any book that:
+ // 1. Has no valid Hardcover ID (can't be processed)
+ // 2. Is not already in Seerr (discovery is the full scan's job)
+ // 3. Already exists in Seerr with the same status (nothing to update)
+ const mediaRepository = getRepository(Media);
+ const existingMedia = await mediaRepository.find({
+ where: { mediaType: MediaType.BOOK },
+ select: ['tmdbId', 'status', 'status4k'],
+ });
+ const existingMediaMap = new Map(existingMedia.map((m) => [m.tmdbId, m]));
+
+ const serverAudio = this.enableAudioBook && server.is4k;
+
+ // Readarr's full book list is authoritative here, so record every book it
+ // still knows about; orphan cleanup relies on a complete set.
+ if (serverAudio) {
+ this.didScanAudio = true;
+ } else {
+ this.didScanStandard = true;
+ }
+
+ allBooks.forEach((book) => {
+ const hcId = parseInt(book.foreignBookId, 10);
+ if (isNaN(hcId)) {
+ return;
+ }
+ if (serverAudio) {
+ this.scannedAudioHcIds.add(hcId);
+ } else {
+ this.scannedHcIds.add(hcId);
+ }
+ });
+
+ this.items = allBooks.filter((book) => {
+ const hcId = parseInt(book.foreignBookId, 10);
+ if (isNaN(hcId)) return false;
+
+ const existing = existingMediaMap.get(hcId);
+ const hasFile = (book.statistics?.bookFileCount ?? 0) > 0;
+
+ if (!existing) {
+ // Book not in Seerr — skip it. Discovery of new books is the
+ // full scan's responsibility.
+ return false;
+ }
+
+ // Check if the status in Seerr matches what Readarr reports
+ const currentStatus = serverAudio ? existing.status4k : existing.status;
+
+ if (hasFile && currentStatus === MediaStatus.AVAILABLE) {
+ return false;
+ }
+
+ if (
+ !hasFile &&
+ (book.monitored || book.grabbed) &&
+ currentStatus === MediaStatus.PROCESSING
+ ) {
+ return false;
+ }
+
+ // Status mismatch — needs updating
+ return true;
+ });
+
+ this.log(
+ `Filtered ${allBooks.length} total books to ${this.items.length} with state changes`,
+ 'info'
+ );
+
+ if (this.items.length === 0) {
+ this.log(
+ `No recently added books found for server: ${server.name}`,
+ 'info'
+ );
+ } else {
+ await this.loop(this.processReadarrBook.bind(this), { sessionId });
+ }
+ }
+
+ private async processReadarrBook(readarrBook: ReadarrBook): Promise {
+ const serverAudio = this.enableAudioBook && this.currentServer.is4k;
+ const hcId = parseInt(readarrBook.foreignBookId, 10);
+
+ if (isNaN(hcId)) {
+ const hasFile = (readarrBook.statistics?.bookFileCount ?? 0) > 0;
+
+ if (!readarrBook.monitored && !readarrBook.grabbed && !hasFile) {
+ return;
+ }
+
+ this.log('Invalid Hardcover ID for book. Skipping item.', 'warn', {
+ title: readarrBook.title,
+ foreignBookId: readarrBook.foreignBookId,
+ });
+ return;
+ }
+
+ if (serverAudio) {
+ this.scannedAudioHcIds.add(hcId);
+ } else {
+ this.scannedHcIds.add(hcId);
+ }
+
+ try {
+ // Use bookFileCount as indicator of having files. In original Readarr,
+ // percentOfBooks (bookFileCount / bookCount * 100) works because
+ // bookCount includes books with files regardless of monitoring state.
+ // Some forks removed that fallback, with bookCount tied to monitoring
+ // state, so unmonitored books with files report percentOfBooks=0.
+ // bookFileCount > 0 inherently implies percentOfBooks >= 100 and works
+ // correctly for both implementations.
+ const hasFile = (readarrBook.statistics?.bookFileCount ?? 0) > 0;
+
+ await this.processBook(hcId, {
+ is4k: serverAudio,
+ serviceId: this.currentServer.id,
+ externalServiceId: readarrBook.id,
+ externalServiceSlug: readarrBook.titleSlug,
+ title: readarrBook.title,
+ processing: !hasFile && (readarrBook.monitored || readarrBook.grabbed),
+ hasFile,
+ });
+ } catch (e) {
+ this.log('Failed to process Readarr media', 'error', {
+ errorMessage: e.message,
+ title: readarrBook.title,
+ });
+ }
+ }
+
+ private async cleanupOrphanedBooks(): Promise {
+ const mediaRepository = getRepository(Media);
+
+ if (this.didScanStandard) {
+ const trackedBooks = await mediaRepository.find({
+ where: {
+ mediaType: MediaType.BOOK,
+ status: In([
+ MediaStatus.PROCESSING,
+ MediaStatus.AVAILABLE,
+ MediaStatus.PARTIALLY_AVAILABLE,
+ ]),
+ },
+ });
+
+ for (const media of trackedBooks) {
+ if (!this.scannedHcIds.has(media.tmdbId)) {
+ media.status = MediaStatus.UNKNOWN;
+ await mediaRepository.save(media);
+ this.log(
+ `Book ${media.tmdbId} not found in any Readarr server. Status reset to UNKNOWN.`,
+ 'info'
+ );
+ }
+ }
+ } else {
+ this.log(
+ 'Skipping orphaned book cleanup: no standard Readarr servers were scanned.',
+ 'info'
+ );
+ }
+
+ if (this.didScanAudio) {
+ const trackedAudioBooks = await mediaRepository.find({
+ where: {
+ mediaType: MediaType.BOOK,
+ status4k: In([
+ MediaStatus.PROCESSING,
+ MediaStatus.AVAILABLE,
+ MediaStatus.PARTIALLY_AVAILABLE,
+ ]),
+ },
+ });
+
+ for (const media of trackedAudioBooks) {
+ if (!this.scannedAudioHcIds.has(media.tmdbId)) {
+ media.status4k = MediaStatus.UNKNOWN;
+ await mediaRepository.save(media);
+ this.log(
+ `Book ${media.tmdbId} not found in any audiobook Readarr server. Audiobook status reset to UNKNOWN.`,
+ 'info'
+ );
+ }
+ }
+ } else if (this.enableAudioBook) {
+ this.log(
+ 'Skipping orphaned audiobook cleanup: no audiobook Readarr servers were scanned.',
+ 'info'
+ );
+ }
+ }
+}
+
+export const readarrScanner = new ReadarrScanner();
+export const readarrRecentScanner = new ReadarrScanner(true);
diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts
index 6e7610620c..b7db415dd4 100644
--- a/server/lib/scanners/sonarr/index.ts
+++ b/server/lib/scanners/sonarr/index.ts
@@ -6,6 +6,7 @@ import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
+ TmdbTvScanDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
@@ -36,6 +37,8 @@ class SonarrScanner
private scanned4kTvdbIds: Set = new Set();
private didScanStandard = false;
private didScan4k = false;
+ private serverReturnedEmpty = false;
+ private server4kReturnedEmpty = false;
constructor() {
super('Sonarr Scan', { bundleSize: 50 });
@@ -58,6 +61,8 @@ class SonarrScanner
this.scanned4kTvdbIds.clear();
this.didScanStandard = false;
this.didScan4k = false;
+ this.serverReturnedEmpty = false;
+ this.server4kReturnedEmpty = false;
try {
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
@@ -90,6 +95,18 @@ class SonarrScanner
this.didScanStandard = true;
}
+ if (this.items.length === 0) {
+ if (server4k) {
+ this.server4kReturnedEmpty = true;
+ } else {
+ this.serverReturnedEmpty = true;
+ }
+ this.log(
+ `Sonarr server ${server.name} returned no series. Orphan cleanup for this profile type will be skipped.`,
+ 'warn'
+ );
+ }
+
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
@@ -114,6 +131,13 @@ class SonarrScanner
this.didScan4k = false;
}
+ if (this.serverReturnedEmpty) {
+ this.didScanStandard = false;
+ }
+ if (this.server4kReturnedEmpty) {
+ this.didScan4k = false;
+ }
+
await this.cleanupOrphanedShows();
this.log('Sonarr scan complete', 'info');
} catch (e) {
@@ -134,18 +158,18 @@ class SonarrScanner
try {
const mediaRepository = getRepository(Media);
const processableSeasons: ProcessableSeason[] = [];
- let tvShow: TmdbTvDetails;
+ let tvShow: TmdbTvScanDetails | TmdbTvDetails;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
- tvShow = await this.tmdb.getShowByTvdbId({
+ tvShow = await this.tmdb.getShowByTvdbIdForScan({
tvdbId: sonarrSeries.tvdbId,
});
} else {
- tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId });
+ tvShow = await this.tmdb.getTvShowForScan({ tvId: media.tmdbId });
}
const tmdbId = tvShow.id;
@@ -158,13 +182,31 @@ class SonarrScanner
if (!(metadataProvider instanceof TheMovieDb)) {
tvShow = await metadataProvider.getTvShow({ tvId: tmdbId });
}
+
const settings = getSettings();
- const filteredSeasons = sonarrSeries.seasons.filter(
- (sn) =>
- tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) &&
- (!settings.main.enableSpecialEpisodes ? sn.seasonNumber !== 0 : true)
- );
+ const filteredSeasons = tvShow.seasons
+ .filter(
+ (sn) => settings.main.enableSpecialEpisodes || sn.season_number !== 0
+ )
+ .map((season) => {
+ const sonarrSeason = sonarrSeries.seasons.find(
+ (s) => s.seasonNumber === season.season_number
+ );
+ if (!sonarrSeason) {
+ return {
+ seasonNumber: season.season_number,
+ episodeCount: season.episode_count,
+ monitored: false,
+ statistics: {
+ episodeFileCount: 0,
+ totalEpisodeCount: season.episode_count,
+ },
+ };
+ } else {
+ return sonarrSeason;
+ }
+ });
for (const season of filteredSeasons) {
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
@@ -194,17 +236,54 @@ class SonarrScanner
}
}
+ private async existsInAnyServer(
+ tvdbId: number,
+ is4k: boolean
+ ): Promise {
+ const servers = this.servers.filter(
+ (server) =>
+ server.syncEnabled && (this.enable4kShow && server.is4k) === is4k
+ );
+
+ for (const server of servers) {
+ try {
+ const api = new SonarrAPI({
+ apiKey: server.apiKey,
+ url: SonarrAPI.buildUrl(server, '/api/v3'),
+ });
+ const series = await api.getLibrarySeriesByTvdbId(tvdbId);
+
+ if (series.some((show) => show.tvdbId === tvdbId)) {
+ return true;
+ }
+ } catch (e) {
+ this.log(
+ `Could not confirm series ${tvdbId} against Sonarr server ${server.name}. Skipping cleanup for it.`,
+ 'warn',
+ { errorMessage: e.message }
+ );
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private async cleanupOrphanedShows(): Promise {
const mediaRepository = getRepository(Media);
if (this.didScanStandard) {
const processingShows = await mediaRepository.find({
where: { mediaType: MediaType.TV, status: MediaStatus.PROCESSING },
- relations: ['seasons'],
+ relations: { seasons: true, requests: true },
});
for (const media of processingShows) {
if (media.tvdbId && !this.scannedTvdbIds.has(media.tvdbId)) {
+ if (await this.existsInAnyServer(media.tvdbId, false)) {
+ continue;
+ }
+
media.status = MediaStatus.UNKNOWN;
for (const season of media.seasons) {
if (season.status === MediaStatus.PROCESSING) {
@@ -212,6 +291,7 @@ class SonarrScanner
}
}
await mediaRepository.save(media);
+ await this.declineOrphanedRequests(media, false);
this.log(
`Show ${media.tmdbId} (tvdb: ${media.tvdbId}) not found in any Sonarr server. Status reset to UNKNOWN.`,
'info'
@@ -228,11 +308,15 @@ class SonarrScanner
if (this.didScan4k) {
const processing4kShows = await mediaRepository.find({
where: { mediaType: MediaType.TV, status4k: MediaStatus.PROCESSING },
- relations: ['seasons'],
+ relations: { seasons: true, requests: true },
});
for (const media of processing4kShows) {
if (media.tvdbId && !this.scanned4kTvdbIds.has(media.tvdbId)) {
+ if (await this.existsInAnyServer(media.tvdbId, true)) {
+ continue;
+ }
+
media.status4k = MediaStatus.UNKNOWN;
for (const season of media.seasons) {
if (season.status4k === MediaStatus.PROCESSING) {
@@ -240,6 +324,7 @@ class SonarrScanner
}
}
await mediaRepository.save(media);
+ await this.declineOrphanedRequests(media, true);
this.log(
`Show ${media.tmdbId} (tvdb: ${media.tvdbId}) not found in any 4K Sonarr server. 4K status reset to UNKNOWN.`,
'info'
diff --git a/server/lib/scanners/sonarr/sonarr.test.ts b/server/lib/scanners/sonarr/sonarr.test.ts
index 0cf32aab03..46994ec4c3 100644
--- a/server/lib/scanners/sonarr/sonarr.test.ts
+++ b/server/lib/scanners/sonarr/sonarr.test.ts
@@ -5,15 +5,23 @@ import type {
TmdbTvDetails,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
-import { MediaStatus, MediaType } from '@server/constants/media';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
+import MediaRequest from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
+import { User } from '@server/entity/User';
+import { sonarrScanner } from '@server/lib/scanners/sonarr';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { setupTestDb } from '@server/test/db';
+import { runWithMockTimers } from '@server/test/runWithMockTimers';
import assert from 'node:assert/strict';
-import { beforeEach, describe, it } from 'node:test';
+import { beforeEach, describe, it, mock } from 'node:test';
let getSeriesImpl: () => Promise = async () => [];
Object.defineProperty(SonarrAPI.prototype, 'getSeries', {
@@ -24,6 +32,17 @@ Object.defineProperty(SonarrAPI.prototype, 'getSeries', {
configurable: true,
});
+let getLibrarySeriesByTvdbIdImpl: (
+ tvdbId: number
+) => Promise = async () => [];
+Object.defineProperty(SonarrAPI.prototype, 'getLibrarySeriesByTvdbId', {
+ set() {},
+ get() {
+ return async (tvdbId: number) => getLibrarySeriesByTvdbIdImpl(tvdbId);
+ },
+ configurable: true,
+});
+
function fakeTmdbShow(
tmdbId: number,
seasons: TmdbTvSeasonResult[] = [
@@ -82,6 +101,10 @@ TheMovieDb.prototype.getShowByTvdbId = async function (args) {
return getShowByTvdbIdImpl(args);
};
+TheMovieDb.prototype.getShowByTvdbIdForScan = async function (args) {
+ return getShowByTvdbIdImpl(args);
+};
+
let getTvShowImpl: (args: {
tvId: number;
language?: string;
@@ -96,7 +119,26 @@ Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
configurable: true,
});
-import { sonarrScanner } from '@server/lib/scanners/sonarr';
+Object.defineProperty(TheMovieDb.prototype, 'getTvShowForScan', {
+ set() {},
+ get() {
+ return async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args);
+ },
+ configurable: true,
+});
+
+// both are assigned in the constructor, so the prototype stubs miss the instance
+// sonarrScanner built when it was first imported
+for (const method of ['getTvShow', 'getTvShowForScan'] as const) {
+ Object.defineProperty(sonarrScanner.tmdb, method, {
+ value: async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args),
+ configurable: true,
+ });
+}
+
+mock.method(MediaRequest, 'sendNotification', async () => undefined);
setupTestDb();
@@ -157,6 +199,7 @@ function configureSonarr(overrides: Partial[] = [{}]): void {
describe('Sonarr Scanner', () => {
beforeEach(() => {
getSeriesImpl = async () => [];
+ getLibrarySeriesByTvdbIdImpl = async () => [];
getShowByTvdbIdImpl = async () => fakeTmdbShow(1);
getTvShowImpl = async () => fakeTmdbShow(1);
});
@@ -186,7 +229,7 @@ describe('Sonarr Scanner', () => {
getSeriesImpl = async () => [];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1050 },
@@ -214,9 +257,9 @@ describe('Sonarr Scanner', () => {
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
- getSeriesImpl = async () => [];
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1000 },
@@ -246,7 +289,7 @@ describe('Sonarr Scanner', () => {
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1001 },
@@ -296,7 +339,7 @@ describe('Sonarr Scanner', () => {
getShowByTvdbIdImpl = async () => fakeTmdbShow(1);
getTvShowImpl = async () => fakeTmdbShow(1);
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1 },
@@ -328,9 +371,9 @@ describe('Sonarr Scanner', () => {
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
- getSeriesImpl = async () => [];
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1003 },
@@ -356,7 +399,7 @@ describe('Sonarr Scanner', () => {
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1004 },
@@ -400,19 +443,12 @@ describe('Sonarr Scanner', () => {
{ syncEnabled: true, id: 1, hostname: 'server-b' },
]);
- let callCount = 0;
- getSeriesImpl = async () => {
- callCount++;
- if (callCount === 2) {
- return [fakeSonarrSeries({ tvdbId: 511 })];
- }
- return [];
- };
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 511 })];
getShowByTvdbIdImpl = async () => fakeTmdbShow(2);
getTvShowImpl = async () => fakeTmdbShow(2);
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updatedOrphan = await mediaRepository.findOneOrFail({
where: { tmdbId: 1010 },
@@ -427,6 +463,79 @@ describe('Sonarr Scanner', () => {
assert.notStrictEqual(updatedExisting.status, MediaStatus.UNKNOWN);
});
+ it('does not reset a show added to Sonarr after the scan started', async () => {
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 1020;
+ media.tvdbId = 620;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.PROCESSING;
+ await mediaRepository.save(media);
+
+ configureSonarr([{ syncEnabled: true }]);
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 111 })];
+ getLibrarySeriesByTvdbIdImpl = async (tvdbId) => [
+ fakeSonarrSeries({ tvdbId }),
+ ];
+
+ await sonarrScanner.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1020 },
+ });
+ assert.strictEqual(updated.status, MediaStatus.PROCESSING);
+ });
+
+ it('does not reset a show when the server cannot be reached', async () => {
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 1021;
+ media.tvdbId = 621;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.PROCESSING;
+ await mediaRepository.save(media);
+
+ configureSonarr([{ syncEnabled: true }]);
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 111 })];
+ getLibrarySeriesByTvdbIdImpl = async () => {
+ throw new Error('connect ECONNREFUSED');
+ };
+
+ await sonarrScanner.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1021 },
+ });
+ assert.strictEqual(updated.status, MediaStatus.PROCESSING);
+ });
+
+ it('resets a show when the server returns no row matching its id', async () => {
+ const mediaRepository = getRepository(Media);
+
+ const media = new Media();
+ media.tmdbId = 1022;
+ media.tvdbId = 622;
+ media.mediaType = MediaType.TV;
+ media.status = MediaStatus.PROCESSING;
+ await mediaRepository.save(media);
+
+ configureSonarr([{ syncEnabled: true }]);
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 111 })];
+ getLibrarySeriesByTvdbIdImpl = async () => [
+ fakeSonarrSeries({ tvdbId: 111 }),
+ fakeSonarrSeries({ tvdbId: 222 }),
+ ];
+
+ await sonarrScanner.run();
+
+ const updated = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 1022 },
+ });
+ assert.strictEqual(updated.status, MediaStatus.UNKNOWN);
+ });
+
it('skips shows without a tvdbId during cleanup', async () => {
const mediaRepository = getRepository(Media);
@@ -438,9 +547,9 @@ describe('Sonarr Scanner', () => {
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
- getSeriesImpl = async () => [];
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1020 },
@@ -469,9 +578,9 @@ describe('Sonarr Scanner', () => {
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true, is4k: true }]);
- getSeriesImpl = async () => [];
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1030 },
@@ -505,9 +614,9 @@ describe('Sonarr Scanner', () => {
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true, is4k: true }]);
- getSeriesImpl = async () => [];
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })];
- await sonarrScanner.run();
+ await runWithMockTimers(() => sonarrScanner.run());
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1031 },
@@ -519,4 +628,307 @@ describe('Sonarr Scanner', () => {
assert.strictEqual(s2?.status4k, MediaStatus.UNKNOWN);
});
});
+
+ describe('orphaned request handling', () => {
+ it('declines the approved request and resets the show to UNKNOWN when orphaned', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 2000,
+ tvdbId: 555,
+ mediaType: MediaType.TV,
+ status: MediaStatus.PROCESSING,
+ seasons: [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ],
+ })
+ );
+
+ const settings = getSettings();
+ settings.sonarr = [];
+ settings.radarr = [];
+ const request = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+
+ configureSonarr([{ syncEnabled: true }]);
+ getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 999 })];
+
+ await runWithMockTimers(() => sonarrScanner.run());
+
+ const updatedMedia = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 2000 },
+ });
+ const updatedRequest = await requestRepository.findOneOrFail({
+ where: { id: request.id },
+ });
+
+ assert.strictEqual(updatedMedia.status, MediaStatus.UNKNOWN);
+ assert.strictEqual(updatedRequest.status, MediaRequestStatus.DECLINED);
+ });
+
+ it('does not decline the request when the show still exists in Sonarr', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 2001,
+ tvdbId: 600,
+ mediaType: MediaType.TV,
+ status: MediaStatus.PROCESSING,
+ seasons: [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ],
+ })
+ );
+
+ const settings = getSettings();
+ settings.sonarr = [];
+ settings.radarr = [];
+ const request = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+
+ configureSonarr([{ syncEnabled: true }]);
+ getSeriesImpl = async () => [
+ fakeSonarrSeries({
+ tvdbId: 600,
+ seasons: [
+ {
+ seasonNumber: 1,
+ monitored: true,
+ statistics: {
+ episodeFileCount: 0,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
+ percentOfEpisodes: 0,
+ sizeOnDisk: 0,
+ previousAiring: undefined,
+ },
+ },
+ ],
+ }),
+ ];
+ getShowByTvdbIdImpl = async () => fakeTmdbShow(2001);
+ getTvShowImpl = async () => fakeTmdbShow(2001);
+
+ await runWithMockTimers(() => sonarrScanner.run());
+
+ const updatedRequest = await requestRepository.findOneOrFail({
+ where: { id: request.id },
+ });
+ assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
+ });
+
+ it('skips cleanup and leaves the request approved when Sonarr returns an empty list', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 2005,
+ tvdbId: 605,
+ mediaType: MediaType.TV,
+ status: MediaStatus.PROCESSING,
+ seasons: [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.UNKNOWN,
+ }),
+ ],
+ })
+ );
+
+ const settings = getSettings();
+ settings.sonarr = [];
+ settings.radarr = [];
+ const request = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+
+ configureSonarr([{ syncEnabled: true }]);
+ getSeriesImpl = async () => [];
+
+ await runWithMockTimers(() => sonarrScanner.run());
+
+ const updatedMedia = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 2005 },
+ });
+ const updatedRequest = await requestRepository.findOneOrFail({
+ where: { id: request.id },
+ });
+
+ assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
+ assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
+ });
+
+ it('declineOrphanedRequests throws when the requests relation is not loaded', async () => {
+ const media = new Media();
+ media.id = 1;
+ media.tmdbId = 123;
+ media.mediaType = MediaType.TV;
+
+ await assert.rejects(
+ () =>
+ (
+ sonarrScanner as unknown as {
+ declineOrphanedRequests: (
+ m: Media,
+ is4k: boolean
+ ) => Promise;
+ }
+ ).declineOrphanedRequests(media, false),
+ /without the 'requests' relation loaded/
+ );
+ });
+
+ it('declines only the 4k request when the 4k dimension is orphaned but standard still exists', async () => {
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
+
+ const requestedBy = await userRepository.findOneOrFail({
+ where: { id: 1 },
+ });
+
+ const media = await mediaRepository.save(
+ new Media({
+ tmdbId: 2002,
+ tvdbId: 666,
+ mediaType: MediaType.TV,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.PROCESSING,
+ seasons: [
+ new Season({
+ seasonNumber: 1,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.PROCESSING,
+ }),
+ ],
+ })
+ );
+
+ const settings = getSettings();
+ settings.sonarr = [];
+ settings.radarr = [];
+ const standardRequest = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: false,
+ })
+ );
+ const fourKRequest = await requestRepository.save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy,
+ is4k: true,
+ })
+ );
+
+ configureSonarr([
+ { syncEnabled: true, id: 0, hostname: 'server-standard' },
+ { syncEnabled: true, id: 1, hostname: 'server-4k', is4k: true },
+ ]);
+
+ let callCount = 0;
+ getSeriesImpl = async () => {
+ callCount++;
+ if (callCount === 1) {
+ // standard server still has the show (processing, no files)
+ return [
+ fakeSonarrSeries({
+ tvdbId: 666,
+ seasons: [
+ {
+ seasonNumber: 1,
+ monitored: true,
+ statistics: {
+ episodeFileCount: 0,
+ totalEpisodeCount: 10,
+ episodeCount: 10,
+ percentOfEpisodes: 0,
+ sizeOnDisk: 0,
+ previousAiring: undefined,
+ },
+ },
+ ],
+ }),
+ ];
+ }
+ // 4k server: populated but the show is absent, so 4k dimension orphaned
+ return [fakeSonarrSeries({ tvdbId: 997 })];
+ };
+
+ getShowByTvdbIdImpl = async ({ tvdbId }) =>
+ tvdbId === 666 ? fakeTmdbShow(2002) : fakeTmdbShow(997);
+ getTvShowImpl = async ({ tvId }) => fakeTmdbShow(tvId);
+
+ await runWithMockTimers(() => sonarrScanner.run());
+
+ const updatedMedia = await mediaRepository.findOneOrFail({
+ where: { tmdbId: 2002 },
+ });
+ const updatedStandard = await requestRepository.findOneOrFail({
+ where: { id: standardRequest.id },
+ });
+ const updated4k = await requestRepository.findOneOrFail({
+ where: { id: fourKRequest.id },
+ });
+
+ assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
+ assert.strictEqual(updatedMedia.status4k, MediaStatus.UNKNOWN);
+ assert.strictEqual(updatedStandard.status, MediaRequestStatus.APPROVED);
+ assert.strictEqual(updated4k.status, MediaRequestStatus.DECLINED);
+ });
+ });
});
diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts
index bb287c8e04..6e62bbdc06 100644
--- a/server/lib/settings/index.ts
+++ b/server/lib/settings/index.ts
@@ -103,6 +103,11 @@ export interface SonarrSettings extends DVRSettings {
monitorNewItems: 'all' | 'none';
}
+export interface ReadarrSettings extends DVRSettings {
+ activeMetadataProfileId: number;
+ activeMetadataProfileName: string;
+}
+
interface Quota {
quotaLimit?: number;
quotaDays?: number;
@@ -131,6 +136,7 @@ export interface ProxySettings {
export interface MainSettings {
apiKey: string;
+ hardcoverapikey: string;
applicationTitle: string;
applicationUrl: string;
cacheImages: boolean;
@@ -138,9 +144,11 @@ export interface MainSettings {
defaultQuotas: {
movie: Quota;
tv: Quota;
+ book: Quota;
};
hideAvailable: boolean;
hideBlocklisted: boolean;
+ hideRequested: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean;
@@ -154,8 +162,11 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
+ syncBookFormatRequests: boolean;
+ seriesRequestsEnabled: boolean;
locale: string;
youtubeUrl: string;
+ versionCheck: boolean;
}
export interface ProxySettings {
@@ -193,10 +204,12 @@ interface FullPublicSettings extends PublicSettings {
applicationUrl: string;
hideAvailable: boolean;
hideBlocklisted: boolean;
+ hideRequested: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
+ bookAudioEnabled: boolean;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
@@ -206,6 +219,8 @@ interface FullPublicSettings extends PublicSettings {
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
+ syncBookFormatRequests: boolean;
+ seriesRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
@@ -214,6 +229,7 @@ interface FullPublicSettings extends PublicSettings {
userEmailRequired: boolean;
newPlexLogin: boolean;
youtubeUrl: string;
+ versionCheck: boolean;
plexClientIdentifier: string;
}
@@ -229,6 +245,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botAvatarUrl?: string;
webhookUrl: string;
webhookRoleId?: string;
+ webhookThreadId?: string;
enableMentions: boolean;
locale: AvailableLocale;
useUserLocale: boolean;
@@ -255,6 +272,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
authPass?: string;
allowSelfSigned: boolean;
senderName: string;
+ usePublicLogo: boolean;
pgpPrivateKey?: string;
pgpPassword?: string;
};
@@ -308,6 +326,7 @@ export interface NotificationAgentNtfy extends NotificationAgentConfig {
options: {
url: string;
topic: string;
+ tags?: string;
authMethodUsernamePassword?: boolean;
username?: string;
password?: string;
@@ -359,6 +378,8 @@ export type JobId =
| 'plex-refresh-token'
| 'radarr-scan'
| 'sonarr-scan'
+ | 'readarr-scan'
+ | 'readarr-recently-added-scan'
| 'download-sync'
| 'download-sync-reset'
| 'jellyfin-recently-added-scan'
@@ -378,6 +399,7 @@ export interface AllSettings {
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
+ readarr: ReadarrSettings[];
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record;
@@ -402,6 +424,7 @@ class Settings {
vapidPublic: '',
main: {
apiKey: '',
+ hardcoverapikey: '',
applicationTitle: 'Seerr',
applicationUrl: '',
cacheImages: false,
@@ -409,9 +432,11 @@ class Settings {
defaultQuotas: {
movie: {},
tv: {},
+ book: {},
},
hideAvailable: false,
hideBlocklisted: false,
+ hideRequested: false,
localLogin: true,
mediaServerLogin: true,
newPlexLogin: true,
@@ -425,8 +450,11 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
+ syncBookFormatRequests: false,
+ seriesRequestsEnabled: true,
locale: 'en',
youtubeUrl: '',
+ versionCheck: true,
},
plex: {
name: '',
@@ -454,6 +482,7 @@ class Settings {
},
radarr: [],
sonarr: [],
+ readarr: [],
public: {
initialized: false,
},
@@ -472,6 +501,7 @@ class Settings {
requireTls: false,
allowSelfSigned: false,
senderName: 'Seerr',
+ usePublicLogo: false,
},
},
discord: {
@@ -557,6 +587,7 @@ class Settings {
options: {
url: '',
topic: '',
+ tags: '',
priority: 3,
locale: 'en',
},
@@ -582,6 +613,12 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
+ 'readarr-scan': {
+ schedule: '0 30 4 * * *',
+ },
+ 'readarr-recently-added-scan': {
+ schedule: '0 */10 * * * *',
+ },
'availability-sync': {
schedule: '0 0 5 * * *',
},
@@ -691,6 +728,14 @@ class Settings {
this.data.sonarr = data;
}
+ get readarr(): ReadarrSettings[] {
+ return this.data.readarr;
+ }
+
+ set readarr(data: ReadarrSettings[]) {
+ this.data.readarr = data;
+ }
+
get public(): PublicSettings {
return this.data.public;
}
@@ -706,6 +751,7 @@ class Settings {
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
hideBlocklisted: this.data.main.hideBlocklisted,
+ hideRequested: this.data.main.hideRequested,
localLogin: this.data.main.localLogin,
mediaServerLogin: this.data.main.mediaServerLogin,
jellyfinExternalHost: this.data.jellyfin.externalHostname,
@@ -716,12 +762,17 @@ class Settings {
series4kEnabled: this.data.sonarr.some(
(sonarr) => sonarr.is4k && sonarr.isDefault
),
+ bookAudioEnabled: this.data.readarr.some(
+ (readarr) => readarr.is4k && readarr.isDefault
+ ),
discoverRegion: this.data.main.discoverRegion,
streamingRegion: this.data.main.streamingRegion,
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
enableSpecialEpisodes: this.data.main.enableSpecialEpisodes,
+ syncBookFormatRequests: this.data.main.syncBookFormatRequests,
+ seriesRequestsEnabled: this.data.main.seriesRequestsEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
@@ -731,6 +782,7 @@ class Settings {
this.data.notifications.agents.email.options.userEmailRequired,
newPlexLogin: this.data.main.newPlexLogin,
youtubeUrl: this.data.main.youtubeUrl,
+ versionCheck: this.data.main.versionCheck,
plexClientIdentifier: this.data.clientId,
};
}
diff --git a/server/lib/watchlistsync.test.ts b/server/lib/watchlistsync.test.ts
new file mode 100644
index 0000000000..659bf2be89
--- /dev/null
+++ b/server/lib/watchlistsync.test.ts
@@ -0,0 +1,198 @@
+import type { PlexWatchlistItem } from '@server/api/plextv';
+import PlexTvAPI from '@server/api/plextv';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import Media from '@server/entity/Media';
+import { MediaRequest } from '@server/entity/MediaRequest';
+import { User } from '@server/entity/User';
+import { UserSettings } from '@server/entity/UserSettings';
+import { Permission } from '@server/lib/permissions';
+import watchlistSync from '@server/lib/watchlistsync';
+import { setupTestDb } from '@server/test/db';
+import assert from 'node:assert/strict';
+import { beforeEach, describe, it } from 'node:test';
+
+let watchlistItems: PlexWatchlistItem[] = [];
+
+Object.defineProperty(PlexTvAPI.prototype, 'getWatchlist', {
+ get() {
+ return async () => ({
+ offset: 0,
+ size: 20,
+ totalSize: watchlistItems.length,
+ items: watchlistItems,
+ });
+ },
+ set() {},
+ configurable: true,
+});
+
+let requestCalls: { mediaId: number; mediaType: MediaType }[] = [];
+
+Object.defineProperty(MediaRequest, 'request', {
+ value: async (body: { mediaId: number; mediaType: MediaType }) => {
+ requestCalls.push({ mediaId: body.mediaId, mediaType: body.mediaType });
+ return {} as MediaRequest;
+ },
+ writable: true,
+ configurable: true,
+});
+
+setupTestDb();
+
+async function configureSyncUser(): Promise {
+ const userRepository = getRepository(User);
+ const admin = await userRepository.findOneOrFail({ where: { id: 1 } });
+
+ admin.plexToken = 'test-plex-token';
+ admin.permissions = Permission.AUTO_REQUEST;
+ await userRepository.save(admin);
+
+ const userSettingsRepository = getRepository(UserSettings);
+ await userSettingsRepository.save(
+ new UserSettings({
+ user: admin,
+ watchlistSyncMovies: true,
+ watchlistSyncTv: true,
+ })
+ );
+
+ return admin;
+}
+
+async function seedMedia(
+ tmdbId: number,
+ mediaType: MediaType,
+ status: MediaStatus
+): Promise {
+ const mediaRepository = getRepository(Media);
+ await mediaRepository.save(
+ new Media({
+ tmdbId,
+ mediaType,
+ status,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+}
+
+function movieItem(tmdbId: number, title: string): PlexWatchlistItem {
+ return { ratingKey: `rk-${tmdbId}`, tmdbId, title, type: 'movie' };
+}
+
+function showItem(tmdbId: number, title: string): PlexWatchlistItem {
+ return {
+ ratingKey: `rk-${tmdbId}`,
+ tmdbId,
+ tvdbId: tmdbId * 1000,
+ title,
+ type: 'show',
+ };
+}
+
+describe('WatchlistSync re-request gating', () => {
+ beforeEach(() => {
+ requestCalls = [];
+ watchlistItems = [];
+ });
+
+ it('re-requests DELETED watchlist items and skips non-requestable ones', async () => {
+ await configureSyncUser();
+
+ await seedMedia(100, MediaType.MOVIE, MediaStatus.DELETED);
+ await seedMedia(101, MediaType.MOVIE, MediaStatus.UNKNOWN);
+ await seedMedia(102, MediaType.MOVIE, MediaStatus.AVAILABLE);
+ await seedMedia(103, MediaType.MOVIE, MediaStatus.BLOCKLISTED);
+
+ await seedMedia(200, MediaType.TV, MediaStatus.DELETED);
+ await seedMedia(201, MediaType.TV, MediaStatus.AVAILABLE);
+
+ watchlistItems = [
+ movieItem(100, 'Deleted Movie'),
+ movieItem(101, 'Unknown Movie'),
+ movieItem(102, 'Available Movie'),
+ movieItem(103, 'Blocklisted Movie'),
+ showItem(200, 'Deleted Show'),
+ showItem(201, 'Available Show'),
+ ];
+
+ await watchlistSync.syncWatchlist();
+
+ const requestedArray = requestCalls.map(
+ (c) => `${c.mediaType}:${c.mediaId}`
+ );
+ const requested = new Set(requestedArray);
+
+ assert.strictEqual(
+ requestedArray.length,
+ requested.size,
+ 'Each item should be requested exactly once'
+ );
+
+ assert.ok(
+ requested.has(`${MediaType.MOVIE}:100`),
+ 'DELETED movie on the watchlist should be re-requested'
+ );
+
+ assert.ok(
+ requested.has(`${MediaType.MOVIE}:101`),
+ 'UNKNOWN movie should be requested'
+ );
+ assert.ok(
+ !requested.has(`${MediaType.MOVIE}:102`),
+ 'AVAILABLE movie should NOT be requested'
+ );
+ assert.ok(
+ !requested.has(`${MediaType.MOVIE}:103`),
+ 'BLOCKLISTED movie should NOT be requested'
+ );
+
+ assert.ok(
+ requested.has(`${MediaType.TV}:200`),
+ 'DELETED show should be re-requested'
+ );
+ assert.ok(
+ !requested.has(`${MediaType.TV}:201`),
+ 'AVAILABLE show should NOT be requested'
+ );
+ });
+
+ it('re-requests DELETED watchlist items even when a stale auto-request exists', async () => {
+ const user = await configureSyncUser();
+
+ await seedMedia(100, MediaType.MOVIE, MediaStatus.DELETED);
+
+ const media = await getRepository(Media).findOneOrFail({
+ where: { tmdbId: 100, mediaType: MediaType.MOVIE },
+ });
+
+ await getRepository(MediaRequest).save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.COMPLETED,
+ media,
+ requestedBy: user,
+ is4k: false,
+ isAutoRequest: true,
+ })
+ );
+
+ watchlistItems = [movieItem(100, 'Deleted Movie')];
+
+ await watchlistSync.syncWatchlist();
+
+ const calls = requestCalls.filter(
+ (c) => c.mediaType === MediaType.MOVIE && c.mediaId === 100
+ );
+
+ assert.strictEqual(
+ calls.length,
+ 1,
+ 'DELETED movie should be re-requested even when a stale auto-request exists'
+ );
+ });
+});
diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts
index 550a6524ad..cb8a5d35e2 100644
--- a/server/lib/watchlistsync.ts
+++ b/server/lib/watchlistsync.ts
@@ -91,7 +91,9 @@ class WatchlistSync {
const autoRequestedTmdbIds = new Set(
existingAutoRequests
- .filter((r) => r.media != null)
+ .filter(
+ (r) => r.media != null && r.media.status !== MediaStatus.DELETED
+ )
.map((r) => `${r.media.mediaType}:${r.media.tmdbId}`)
);
@@ -106,7 +108,8 @@ class WatchlistSync {
m.mediaType === itemMediaType &&
(m.status === MediaStatus.BLOCKLISTED ||
(itemMediaType === MediaType.MOVIE &&
- m.status !== MediaStatus.UNKNOWN) ||
+ m.status !== MediaStatus.UNKNOWN &&
+ m.status !== MediaStatus.DELETED) ||
(itemMediaType === MediaType.TV &&
m.status === MediaStatus.AVAILABLE))
)
diff --git a/server/migration/postgres/1779783365432-AddDiscordIdsColumn.ts b/server/migration/postgres/1779783365432-AddDiscordIdsColumn.ts
new file mode 100644
index 0000000000..98a04bd8a7
--- /dev/null
+++ b/server/migration/postgres/1779783365432-AddDiscordIdsColumn.ts
@@ -0,0 +1,30 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddDiscordIdsColumn1779783365432 implements MigrationInterface {
+ name = 'AddDiscordIdsColumn1779783365432';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" ADD "discordIds" text`
+ );
+ // same for postgres (convert existing single ID into list with one entry)
+ await queryRunner.query(
+ `UPDATE "user_settings" SET "discordIds" = '["' || "discordId" || '"]' WHERE "discordId" IS NOT NULL AND "discordId" != ''`
+ );
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" DROP COLUMN "discordId"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" ADD "discordId" character varying`
+ );
+ await queryRunner.query(
+ `UPDATE "user_settings" SET "discordId" = ("discordIds"::jsonb ->> 0) WHERE "discordIds" IS NOT NULL AND "discordIds" != ''`
+ );
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" DROP COLUMN "discordIds"`
+ );
+ }
+}
diff --git a/server/migration/postgres/1781732098511-AddIgnoreQuotaToMediaRequest.ts b/server/migration/postgres/1781732098511-AddIgnoreQuotaToMediaRequest.ts
new file mode 100644
index 0000000000..a575e153c5
--- /dev/null
+++ b/server/migration/postgres/1781732098511-AddIgnoreQuotaToMediaRequest.ts
@@ -0,0 +1,17 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddIgnoreQuotaToMediaRequest1781732098511 implements MigrationInterface {
+ name = 'AddIgnoreQuotaToMediaRequest1781732098511';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "media_request" ADD "ignoreQuota" boolean NOT NULL DEFAULT false`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "media_request" DROP COLUMN "ignoreQuota"`
+ );
+ }
+}
diff --git a/server/migration/postgres/1789254652580-RemapOverseerrDeletedStatus.ts b/server/migration/postgres/1789254652580-RemapOverseerrDeletedStatus.ts
new file mode 100644
index 0000000000..2733c68ed5
--- /dev/null
+++ b/server/migration/postgres/1789254652580-RemapOverseerrDeletedStatus.ts
@@ -0,0 +1,28 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+// Hand written as its data only because Overseerr's MediaStatus.DELETED = 6
+// collides with our BLOCKLISTED, so rows from an imported database read as blocklisted.
+// see https://github.com/seerr-team/seerr/issues/3508
+export class RemapOverseerrDeletedStatus1789257612345 implements MigrationInterface {
+ name = 'RemapOverseerrDeletedStatus1789257612345';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `UPDATE "season" SET "status" = 7 WHERE "status" = 6`
+ );
+ await queryRunner.query(
+ `UPDATE "season" SET "status4k" = 7 WHERE "status4k" = 6`
+ );
+ await queryRunner.query(
+ `UPDATE "media" SET "status" = 7 WHERE "status" = 6 AND NOT EXISTS (SELECT 1 FROM "blocklist" WHERE "blocklist"."tmdbId" = "media"."tmdbId" AND "blocklist"."mediaType" = "media"."mediaType")`
+ );
+ await queryRunner.query(
+ `UPDATE "media" SET "status4k" = 7 WHERE "status4k" = 6 AND NOT EXISTS (SELECT 1 FROM "blocklist" WHERE "blocklist"."tmdbId" = "media"."tmdbId" AND "blocklist"."mediaType" = "media"."mediaType")`
+ );
+ }
+
+ public async down(): Promise {
+ // Intentionally left empty as remapped rows are indistinguishable from rows
+ // that were already DELETED.
+ }
+}
diff --git a/server/migration/sqlite/1779783365432-AddDiscordIdsColumn.ts b/server/migration/sqlite/1779783365432-AddDiscordIdsColumn.ts
new file mode 100644
index 0000000000..36fb1ad59a
--- /dev/null
+++ b/server/migration/sqlite/1779783365432-AddDiscordIdsColumn.ts
@@ -0,0 +1,31 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddDiscordIdsColumn1779783365432 implements MigrationInterface {
+ name = 'AddDiscordIdsColumn1779783365432';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordIds" text, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordIds", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", CASE WHEN "discordId" IS NOT NULL AND "discordId" != '' THEN '["' || "discordId" || '"]' ELSE NULL END, "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", json_extract("discordIds", '$[0]'), "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/migration/sqlite/1781732036510-AddIgnoreQuotaToMediaRequest.ts b/server/migration/sqlite/1781732036510-AddIgnoreQuotaToMediaRequest.ts
new file mode 100644
index 0000000000..4ab2c1fdc0
--- /dev/null
+++ b/server/migration/sqlite/1781732036510-AddIgnoreQuotaToMediaRequest.ts
@@ -0,0 +1,119 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddIgnoreQuotaToMediaRequest1781732036510 implements MigrationInterface {
+ name = 'AddIgnoreQuotaToMediaRequest1781732036510';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
+ 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_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), 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 INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
+ );
+ 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_4c696e8ed36ae34fe18abe59d2"`);
+ await queryRunner.query(
+ `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), "ignoreQuota" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "media_request"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") `
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") `
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") `
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_4c696e8ed36ae34fe18abe59d2" ON "media_request" ("status") `
+ );
+ await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
+ 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_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), 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 INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
+ 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_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), 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_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
+ );
+ await queryRunner.query(`DROP INDEX "IDX_4c696e8ed36ae34fe18abe59d2"`);
+ await queryRunner.query(`DROP INDEX "IDX_a1aa713f41c99e9d10c48da75a"`);
+ await queryRunner.query(`DROP INDEX "IDX_6997bee94720f1ecb7f3113709"`);
+ await queryRunner.query(`DROP INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1"`);
+ await queryRunner.query(
+ `ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "temporary_media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_media_request"`);
+ await queryRunner.query(
+ `CREATE INDEX "IDX_4c696e8ed36ae34fe18abe59d2" ON "media_request" ("status") `
+ );
+ 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(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
+ 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_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), 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_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
+ );
+ }
+}
diff --git a/server/migration/sqlite/1786619443939-DropPushSubscriptionAuthUnique.ts b/server/migration/sqlite/1786619443939-DropPushSubscriptionAuthUnique.ts
new file mode 100644
index 0000000000..f2d81695dc
--- /dev/null
+++ b/server/migration/sqlite/1786619443939-DropPushSubscriptionAuthUnique.ts
@@ -0,0 +1,41 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+// SQLite kept the "auth" UNIQUE that Postgres dropped in 1743023615532-UpdateWebPush.
+// Hand-written because the generated rebuild re-adds it and never converges.
+export class DropPushSubscriptionAuthUnique1786619443939 implements MigrationInterface {
+ name = 'DropPushSubscriptionAuthUnique1786619443939';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
+ 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 "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 INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`);
+ 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_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), 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_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") `
+ );
+ }
+}
diff --git a/server/migration/sqlite/1789254652493-RemapOverseerrDeletedStatus.ts b/server/migration/sqlite/1789254652493-RemapOverseerrDeletedStatus.ts
new file mode 100644
index 0000000000..2733c68ed5
--- /dev/null
+++ b/server/migration/sqlite/1789254652493-RemapOverseerrDeletedStatus.ts
@@ -0,0 +1,28 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+// Hand written as its data only because Overseerr's MediaStatus.DELETED = 6
+// collides with our BLOCKLISTED, so rows from an imported database read as blocklisted.
+// see https://github.com/seerr-team/seerr/issues/3508
+export class RemapOverseerrDeletedStatus1789257612345 implements MigrationInterface {
+ name = 'RemapOverseerrDeletedStatus1789257612345';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `UPDATE "season" SET "status" = 7 WHERE "status" = 6`
+ );
+ await queryRunner.query(
+ `UPDATE "season" SET "status4k" = 7 WHERE "status4k" = 6`
+ );
+ await queryRunner.query(
+ `UPDATE "media" SET "status" = 7 WHERE "status" = 6 AND NOT EXISTS (SELECT 1 FROM "blocklist" WHERE "blocklist"."tmdbId" = "media"."tmdbId" AND "blocklist"."mediaType" = "media"."mediaType")`
+ );
+ await queryRunner.query(
+ `UPDATE "media" SET "status4k" = 7 WHERE "status4k" = 6 AND NOT EXISTS (SELECT 1 FROM "blocklist" WHERE "blocklist"."tmdbId" = "media"."tmdbId" AND "blocklist"."mediaType" = "media"."mediaType")`
+ );
+ }
+
+ public async down(): Promise {
+ // Intentionally left empty as remapped rows are indistinguishable from rows
+ // that were already DELETED.
+ }
+}
diff --git a/server/models/Author.ts b/server/models/Author.ts
new file mode 100644
index 0000000000..f1ca1913ec
--- /dev/null
+++ b/server/models/Author.ts
@@ -0,0 +1,25 @@
+import type { HardcoverAuthorDetails } from '@server/api/hardcover/interfaces';
+import type { BookResult } from './Search';
+
+export interface AuthorDetails {
+ bio: string;
+ books_count: number;
+ id: number;
+ name: string;
+ born_date: string;
+ posterPath?: string;
+ books?: BookResult[];
+}
+
+export const mapAuthorDetails = (
+ author: HardcoverAuthorDetails,
+ books?: BookResult[]
+): AuthorDetails => ({
+ id: author.id,
+ name: author.name,
+ born_date: author.born_date,
+ books_count: author.books_count,
+ posterPath: author.image?.url,
+ bio: author.bio,
+ books: books,
+});
diff --git a/server/models/Book.ts b/server/models/Book.ts
new file mode 100644
index 0000000000..09a4292906
--- /dev/null
+++ b/server/models/Book.ts
@@ -0,0 +1,93 @@
+import { getHeader } from '@server/api/hardcover';
+import type {
+ Author,
+ HardcoverBookDetails,
+ HardcoverSeries,
+ Tags,
+} from '@server/api/hardcover/interfaces';
+import type Media from '@server/entity/Media';
+import type { ExternalIds } from './common';
+
+export interface BookDetails {
+ id: number;
+ title: string;
+ description: string;
+ releaseDate: string;
+ image?: {
+ url: string;
+ };
+ mediaInfo?: Media;
+ externalIds?: ExternalIds;
+ author: Author[];
+ pages: number;
+ tags: Tags;
+ headline: string;
+ subtitle: string;
+ series: {
+ position: number;
+ series_id: number;
+ series: HardcoverSeries;
+ }[];
+ posterPath: string;
+ backdropPath: string;
+ slug: string;
+ rating?: number;
+ ratingsCount?: number;
+ reviewsCount?: number;
+ publisher?: string;
+ language?: string;
+ isbn?: string;
+ format?: string;
+ hasPhysicalEdition: boolean;
+ hasEbookEdition: boolean;
+ hasAudioEdition: boolean;
+ audiobookDuration?: number;
+ narrators?: string[];
+}
+
+export const mapBookDetails = (
+ book: HardcoverBookDetails,
+ media?: Media
+): BookDetails => ({
+ id: book.id,
+ title: book.title,
+ description: book.description,
+ releaseDate: book.release_date,
+ tags: book.cached_tags,
+ headline: book.headline,
+ subtitle: book.subtitle,
+ pages: book.pages,
+ mediaInfo: media,
+ author: book.contributions.map((e) => e.author),
+ series: book.book_series,
+ posterPath: book.image?.url
+ ? book.image.url
+ : `https://assets.hardcover.app/static/covers/cover${
+ (book.id % 9) + 1
+ }.png`,
+ backdropPath: (() => {
+ const header = getHeader(book.cached_tags);
+ if (header) {
+ return `https://assets.hardcover.app/static/bookHeaders/${header}.webp`;
+ }
+ return '';
+ })(),
+ slug: book.slug,
+ rating: book.rating,
+ ratingsCount: book.ratings_count ?? 0,
+ reviewsCount: book.reviews_count ?? 0,
+ publisher: book.default_physical_edition?.publisher?.name,
+ language: book.default_physical_edition?.language?.language,
+ isbn:
+ book.default_physical_edition?.isbn_13 ??
+ book.default_physical_edition?.isbn_10 ??
+ book.default_ebook_edition?.isbn_13,
+ format: book.default_physical_edition?.physical_format,
+ hasPhysicalEdition: !!book.default_physical_edition_id,
+ hasEbookEdition: !!book.default_ebook_edition_id,
+ hasAudioEdition: !!book.default_audio_edition_id,
+ audiobookDuration: book.default_audio_edition?.audio_seconds,
+ narrators: book.default_audio_edition?.contributions
+ ?.map((c) => c.author?.name)
+ .filter(Boolean) as string[] | undefined,
+});
diff --git a/server/models/Search.ts b/server/models/Search.ts
index 2193bbe158..9b244fb841 100644
--- a/server/models/Search.ts
+++ b/server/models/Search.ts
@@ -1,3 +1,8 @@
+import { getHeader } from '@server/api/hardcover';
+import type {
+ HardcoverAuthorResult,
+ HardcoverBookResult,
+} from '@server/api/hardcover/interfaces';
import type {
TmdbCollectionResult,
TmdbMovieDetails,
@@ -10,7 +15,7 @@ import type {
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
-export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
+export type MediaType = 'tv' | 'movie' | 'person' | 'collection' | 'book';
interface SearchResult {
id: number;
@@ -36,6 +41,19 @@ export interface MovieResult extends SearchResult {
mediaInfo?: Media;
}
+export interface BookResult {
+ id: number;
+ mediaType: 'book';
+ title: string;
+ overview: string;
+ releaseDate: string;
+ posterPath?: string;
+ backdropPath?: string;
+ position?: number;
+ extra?: boolean;
+ mediaInfo?: Media;
+}
+
export interface TvResult extends SearchResult {
mediaType: 'tv';
name: string;
@@ -66,7 +84,20 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[];
}
-export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
+export interface AuthorResult {
+ id: number;
+ name: string;
+ image?: string;
+ mediaType: 'author';
+}
+
+export type Results =
+ | MovieResult
+ | TvResult
+ | PersonResult
+ | CollectionResult
+ | BookResult
+ | AuthorResult;
export const mapMovieResult = (
movieResult: TmdbMovieResult,
@@ -90,6 +121,43 @@ export const mapMovieResult = (
mediaInfo: media,
});
+export const mapBookResult = (
+ bookResult: HardcoverBookResult,
+ media?: Media,
+ position?: number
+): BookResult => ({
+ id: bookResult.id,
+ mediaType: 'book',
+ title: bookResult.title,
+ overview: bookResult.description,
+ releaseDate: bookResult.release_date,
+ posterPath: bookResult.image?.url
+ ? bookResult.image.url
+ : `https://assets.hardcover.app/static/covers/cover${
+ (bookResult.id % 9) + 1
+ }.png`,
+ backdropPath: (() => {
+ if (bookResult.cached_tags) {
+ const header = getHeader(bookResult.cached_tags);
+ if (header) {
+ return `https://assets.hardcover.app/static/bookHeaders/${header}.webp`;
+ }
+ }
+ return '';
+ })(),
+ position: position,
+ mediaInfo: media,
+});
+
+export const mapAuthorResult = (
+ authorResult: HardcoverAuthorResult
+): AuthorResult => ({
+ id: authorResult.id,
+ name: authorResult.name,
+ image: authorResult?.image?.url,
+ mediaType: 'author',
+});
+
export const mapTvResult = (
tvResult: TmdbTvResult,
media?: Media
@@ -150,6 +218,8 @@ export const mapSearchResults = (
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
+ | HardcoverBookResult
+ | HardcoverAuthorResult
)[],
media?: Media[]
): Results[] =>
@@ -171,10 +241,21 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
+ case 'book':
+ return mapBookResult(
+ result,
+ media?.find(
+ (req) =>
+ req.tmdbId === Number(result.id) &&
+ req.mediaType === MainMediaType.BOOK
+ )
+ );
case 'collection':
return mapCollectionResult(result);
- default:
+ case 'person':
return mapPersonResult(result);
+ case 'author':
+ return mapAuthorResult(result);
}
});
diff --git a/server/models/Series.ts b/server/models/Series.ts
new file mode 100644
index 0000000000..16349fa90d
--- /dev/null
+++ b/server/models/Series.ts
@@ -0,0 +1,34 @@
+import type { HardcoverSeries } from '@server/api/hardcover/interfaces';
+import { MediaType } from '@server/constants/media';
+import type Media from '@server/entity/Media';
+import type { BookResult } from './Search';
+import { mapBookResult } from './Search';
+
+export interface Series {
+ id: number;
+ name: string;
+ overview?: string;
+ posterPath?: string;
+ backdropPath?: string;
+ books: BookResult[];
+}
+
+export const mapSeries = (series: HardcoverSeries, media: Media[]): Series => ({
+ id: series.id,
+ name: series.name,
+ overview: series.description,
+ // Hardcover numbers dramatized adaptations, split editions and bonus
+ // chapters off the book they belong to, so a fractional position marks an
+ // edition or extra rather than one of the series' own books
+ books: series.book_series.map((book_series) => ({
+ ...mapBookResult(
+ book_series.book,
+ media?.find(
+ (req) =>
+ req.tmdbId === book_series.book.id && req.mediaType === MediaType.BOOK
+ ),
+ book_series.position
+ ),
+ extra: !Number.isInteger(Number(book_series.position)),
+ })),
+});
diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts
index 0e7981a23d..d787165cfc 100644
--- a/server/routes/auth.test.ts
+++ b/server/routes/auth.test.ts
@@ -1,12 +1,24 @@
import assert from 'node:assert/strict';
import { before, beforeEach, describe, it, mock } from 'node:test';
+import JellyfinAPI from '@server/api/jellyfin';
+import { ApiErrorCode } from '@server/constants/error';
+import { MediaServerType } from '@server/constants/server';
+import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import PreparedEmail from '@server/lib/email';
+import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import { checkUser } from '@server/middleware/auth';
import { setupTestDb } from '@server/test/db';
+import {
+ allowlistedSettings,
+ assertNoCredentials,
+ seedUserSettings,
+} from '@server/test/userSettings';
+import { ApiError } from '@server/types/error';
+import axios from 'axios';
import type { Express } from 'express';
import express from 'express';
import session from 'express-session';
@@ -17,6 +29,81 @@ const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => {
return undefined;
}).mock;
+// Jellyfin Quick Connect mocks
+const defaultInitiateResponse = {
+ Secret: 'abc123def456abc123def456',
+ Code: '123456',
+ DateAdded: new Date().toISOString(),
+};
+
+const defaultCheckResponse = {
+ Authenticated: false,
+ Secret: 'abc123def456abc123def456',
+ Code: '123456',
+ DeviceId: 'device-1',
+ DeviceName: 'Test',
+ AppName: 'Seerr',
+ AppVersion: '1.0',
+ DateAdded: new Date().toISOString(),
+};
+
+const defaultAuthenticateResponse = {
+ User: {
+ Id: 'jf-qc-user-001',
+ Name: 'quickconnectuser',
+ ServerId: 'server-1',
+ Policy: { IsAdministrator: false },
+ },
+ AccessToken: 'fake-qc-access-token',
+};
+
+const initiateQCMock = mock.method(
+ JellyfinAPI.prototype,
+ 'initiateQuickConnect',
+ async () => ({ ...defaultInitiateResponse })
+);
+
+const checkQCMock = mock.method(
+ JellyfinAPI.prototype,
+ 'checkQuickConnect',
+ async () => ({ ...defaultCheckResponse })
+);
+
+const authenticateQCMock = mock.method(
+ JellyfinAPI.prototype,
+ 'authenticateQuickConnect',
+ async () => ({ ...defaultAuthenticateResponse })
+);
+const fakeAvatarBuffer = Buffer.from('fake-quickconnect-avatar-bytes');
+
+const axiosHeadMock = mock.method(axios, 'head', async () => ({
+ status: 200,
+ headers: { 'last-modified': 'Wed, 01 Jan 2025 00:00:00 GMT' },
+}));
+
+const clearCachedImageMock = mock.method(
+ ImageProxy.prototype,
+ 'clearCachedImage',
+ async () => undefined
+);
+
+const getImageMock = mock.method(
+ ImageProxy.prototype,
+ 'getImage',
+ async () => ({
+ imageBuffer: fakeAvatarBuffer,
+ meta: {
+ revalidateAfter: 3600,
+ curRevalidate: 3600,
+ isStale: false,
+ etag: 'mock-meta-etag',
+ extension: 'jpg',
+ cacheKey: 'mock-cache-key',
+ cacheMiss: true,
+ },
+ })
+);
+
let app: Express;
function createApp() {
@@ -67,6 +154,427 @@ async function authenticatedAgent(email: string, password: string) {
return agent;
}
+/** Configure Jellyfin settings for testing QC */
+function configureJellyfin() {
+ const settings = getSettings();
+ settings.main.mediaServerType = MediaServerType.JELLYFIN;
+ settings.main.newPlexLogin = true;
+ settings.jellyfin.ip = 'localhost';
+ settings.jellyfin.port = 8096;
+ settings.jellyfin.useSsl = false;
+ settings.jellyfin.urlBase = '';
+}
+
+describe('POST /auth/jellyfin/quickconnect/initiate', () => {
+ beforeEach(() => {
+ initiateQCMock.mock.resetCalls();
+ initiateQCMock.mock.mockImplementation(async () => ({
+ ...defaultInitiateResponse,
+ }));
+ configureJellyfin();
+ });
+
+ it('returns code and secret on success', async () => {
+ const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.code, '123456');
+ assert.strictEqual(res.body.secret, 'abc123def456abc123def456');
+ assert.strictEqual(initiateQCMock.mock.callCount(), 1);
+ });
+
+ it('returns 403 when the media server is Emby', async () => {
+ getSettings().main.mediaServerType = MediaServerType.EMBY;
+
+ const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(initiateQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 500 when Jellyfin API fails', async () => {
+ initiateQCMock.mock.mockImplementation(async () => {
+ throw new Error('Connection refused');
+ });
+
+ const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
+
+ assert.strictEqual(res.status, 500);
+ assert.match(res.body.message, /initiate quick connect/i);
+ });
+
+ it('returns 500 when initiateQuickConnect throws ApiError', async () => {
+ initiateQCMock.mock.mockImplementation(async () => {
+ throw new ApiError(500, ApiErrorCode.Unknown);
+ });
+
+ const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
+ assert.strictEqual(res.status, 500);
+ });
+});
+
+describe('GET /auth/jellyfin/quickconnect/check', () => {
+ beforeEach(() => {
+ checkQCMock.mock.resetCalls();
+ checkQCMock.mock.mockImplementation(async () => ({
+ ...defaultCheckResponse,
+ }));
+ configureJellyfin();
+ });
+
+ it('returns authenticated: false when not yet authorized', async () => {
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.authenticated, false);
+ assert.strictEqual(checkQCMock.mock.callCount(), 1);
+ });
+
+ it('returns authenticated: true when authorized', async () => {
+ checkQCMock.mock.mockImplementation(async () => ({
+ ...defaultCheckResponse,
+ Authenticated: true,
+ }));
+
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.authenticated, true);
+ });
+
+ it('returns 400 when secret is missing', async () => {
+ const res = await request(app).get('/auth/jellyfin/quickconnect/check');
+
+ assert.strictEqual(res.status, 400);
+ assert.match(res.body.message, /invalid secret/i);
+ assert.strictEqual(checkQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 400 when secret is too short', async () => {
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'ab12' });
+
+ assert.strictEqual(res.status, 400);
+ assert.match(res.body.message, /invalid secret/i);
+ assert.strictEqual(checkQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 400 when secret is too long', async () => {
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'a'.repeat(129) });
+
+ assert.strictEqual(res.status, 400);
+ assert.match(res.body.message, /invalid secret/i);
+ assert.strictEqual(checkQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 400 when secret contains non-hex characters', async () => {
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'zzzzzzzzzzzz' });
+
+ assert.strictEqual(res.status, 400);
+ assert.match(res.body.message, /invalid secret/i);
+ assert.strictEqual(checkQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 403 when the media server is Emby', async () => {
+ getSettings().main.mediaServerType = MediaServerType.EMBY;
+
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(checkQCMock.mock.callCount(), 0);
+ });
+
+ it('returns error when Jellyfin API fails', async () => {
+ checkQCMock.mock.mockImplementation(async () => {
+ throw new ApiError(500, ApiErrorCode.Unknown);
+ });
+
+ const res = await request(app)
+ .get('/auth/jellyfin/quickconnect/check')
+ .query({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 500);
+ });
+});
+
+describe('POST /auth/jellyfin/quickconnect/authenticate', () => {
+ beforeEach(() => {
+ authenticateQCMock.mock.resetCalls();
+ authenticateQCMock.mock.mockImplementation(async () => ({
+ ...defaultAuthenticateResponse,
+ }));
+ axiosHeadMock.mock.resetCalls();
+ clearCachedImageMock.mock.resetCalls();
+ getImageMock.mock.resetCalls();
+ configureJellyfin();
+ });
+
+ it('returns 400 when secret is missing', async () => {
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({});
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 400 when secret is not a string', async () => {
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 12345678 });
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 400 when secret is too short', async () => {
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'ab12' });
+
+ assert.strictEqual(res.status, 400);
+ });
+
+ it('returns 400 when secret contains non-hex characters', async () => {
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'zzzzzzzzzzzz' });
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 403 when media server is not configured', async () => {
+ const settings = getSettings();
+ settings.main.mediaServerType = MediaServerType.NOT_CONFIGURED;
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 403);
+ assert.match(res.body.message, /initial setup/i);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
+ });
+
+ it('returns 403 when no users exist in the database', async () => {
+ // Clear all users to simulate initial setup
+ const userRepo = getRepository(User);
+ await userRepo.clear();
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 403);
+ assert.match(res.body.message, /initial setup/i);
+ });
+
+ it('signs in an existing Jellyfin user and sets session', async () => {
+ const userRepo = getRepository(User);
+ const existingUser = new User({
+ email: 'existing-qc@seerr.dev',
+ jellyfinUsername: 'quickconnectuser',
+ jellyfinUserId: 'jf-qc-user-001',
+ jellyfinDeviceId: 'old-device-id',
+ permissions: 0,
+ avatar: '/avatarproxy/jf-qc-user-001?v=0',
+ userType: UserType.JELLYFIN,
+ });
+ await userRepo.save(existingUser);
+
+ const agent = request.agent(app);
+
+ const res = await agent
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 200);
+ assert.ok('id' in res.body);
+ assert.ok(!('password' in res.body));
+
+ const meRes = await agent.get('/auth/me');
+ assert.strictEqual(meRes.status, 200);
+ assert.strictEqual(meRes.body.jellyfinUsername, 'quickconnectuser');
+
+ const updatedUser = await userRepo.findOneOrFail({
+ where: { jellyfinUserId: 'jf-qc-user-001' },
+ select: {
+ id: true,
+ jellyfinAuthToken: true,
+ jellyfinDeviceId: true,
+ },
+ });
+ assert.strictEqual(updatedUser.jellyfinAuthToken, 'fake-qc-access-token');
+ assert.notStrictEqual(updatedUser.jellyfinDeviceId, 'old-device-id');
+ });
+
+ it('refreshes avatarVersion/avatarETag when the remote avatar has changed', async () => {
+ const userRepo = getRepository(User);
+ const existingUser = new User({
+ email: 'qc-avatar-change@seerr.dev',
+ jellyfinUsername: 'quickconnectuser',
+ jellyfinUserId: 'jf-qc-user-001',
+ jellyfinDeviceId: 'old-device-id',
+ permissions: 0,
+ avatar: '/avatarproxy/jf-qc-user-001?v=old',
+ avatarVersion: 'old-version',
+ avatarETag: 'old-etag',
+ userType: UserType.JELLYFIN,
+ });
+ await userRepo.save(existingUser);
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 200);
+
+ const updatedUser = await userRepo.findOneOrFail({
+ where: { jellyfinUserId: 'jf-qc-user-001' },
+ });
+ assert.notStrictEqual(updatedUser.avatarVersion, 'old-version');
+ assert.notStrictEqual(updatedUser.avatarETag, 'old-etag');
+ assert.notStrictEqual(
+ updatedUser.avatar,
+ '/avatarproxy/jf-qc-user-001?v=old'
+ );
+ });
+
+ it('creates a new user when newPlexLogin is enabled and user does not exist', async () => {
+ const settings = getSettings();
+ settings.main.newPlexLogin = true;
+
+ authenticateQCMock.mock.mockImplementation(async () => ({
+ User: {
+ Id: 'jf-brand-new-user',
+ Name: 'brandnewuser',
+ ServerId: 'server-1',
+ Policy: { IsAdministrator: false },
+ },
+ AccessToken: 'new-user-token',
+ }));
+
+ const agent = request.agent(app);
+
+ const res = await agent
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 200);
+ assert.ok('id' in res.body);
+
+ const userRepo = getRepository(User);
+ const newUser = await userRepo.findOne({
+ where: { jellyfinUserId: 'jf-brand-new-user' },
+ });
+ assert.ok(newUser);
+ assert.strictEqual(newUser.jellyfinUsername, 'brandnewuser');
+ assert.strictEqual(newUser.userType, UserType.JELLYFIN);
+
+ const meRes = await agent.get('/auth/me');
+ assert.strictEqual(meRes.status, 200);
+ });
+
+ it('returns 403 when the media server is Emby', async () => {
+ getSettings().main.mediaServerType = MediaServerType.EMBY;
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
+ });
+
+ it('applies default permissions to newly created users', async () => {
+ const settings = getSettings();
+ settings.main.newPlexLogin = true;
+ settings.main.defaultPermissions = 32;
+
+ authenticateQCMock.mock.mockImplementation(async () => ({
+ User: {
+ Id: 'jf-perms-test-user',
+ Name: 'permsuser',
+ ServerId: 'server-1',
+ Policy: { IsAdministrator: false },
+ },
+ AccessToken: 'perms-token',
+ }));
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 200);
+
+ const userRepo = getRepository(User);
+ const user = await userRepo.findOneOrFail({
+ where: { jellyfinUserId: 'jf-perms-test-user' },
+ });
+ assert.strictEqual(user.permissions, 32);
+ });
+
+ it('returns 403 when newPlexLogin is disabled and user does not exist', async () => {
+ const settings = getSettings();
+ settings.main.newPlexLogin = false;
+
+ authenticateQCMock.mock.mockImplementation(async () => ({
+ User: {
+ Id: 'jf-unknown-user',
+ Name: 'unknownuser',
+ ServerId: 'server-1',
+ Policy: { IsAdministrator: false },
+ },
+ AccessToken: 'unknown-token',
+ }));
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(res.body.message, 'Access denied.');
+ });
+
+ it('returns error when Jellyfin authenticateQuickConnect fails', async () => {
+ authenticateQCMock.mock.mockImplementation(async () => {
+ throw new ApiError(401, ApiErrorCode.InvalidCredentials);
+ });
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 401);
+ assert.strictEqual(res.body.message, ApiErrorCode.InvalidCredentials);
+ });
+
+ it('returns 500 when Jellyfin throws a generic error', async () => {
+ authenticateQCMock.mock.mockImplementation(async () => {
+ throw new Error('Network timeout');
+ });
+
+ const res = await request(app)
+ .post('/auth/jellyfin/quickconnect/authenticate')
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 500);
+ });
+});
+
describe('GET /auth/me', () => {
it('returns 403 when not authenticated', async () => {
const res = await request(app).get('/auth/me');
@@ -110,6 +618,30 @@ describe('GET /auth/me', () => {
settings.notifications.agents.email.options.userEmailRequired = false;
});
+
+ it('returns only the allowlisted settings fields', async () => {
+ await seedUserSettings('admin@seerr.dev');
+ const agent = await authenticatedAgent('admin@seerr.dev', 'test1234');
+
+ const res = await agent.get('/auth/me');
+
+ assert.strictEqual(res.status, 200);
+ assert.deepStrictEqual(Object.keys(res.body.settings).sort(), [
+ 'discoverRegion',
+ 'locale',
+ 'notificationTypes',
+ 'originalLanguage',
+ 'streamingRegion',
+ 'watchlistSyncMovies',
+ 'watchlistSyncTv',
+ ]);
+ assert.strictEqual(res.body.settings.locale, allowlistedSettings.locale);
+ assert.strictEqual(
+ res.body.settings.discoverRegion,
+ allowlistedSettings.discoverRegion
+ );
+ assertNoCredentials(res.body);
+ });
});
describe('POST /auth/local', () => {
@@ -189,7 +721,7 @@ describe('POST /auth/local', () => {
it('allows the non-admin user to log in', async () => {
const res = await request(app)
.post('/auth/local')
- .send({ email: 'friend@seerr.dev', password: 'test1234' });
+ .send({ email: 'demo@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
index f625e68f0e..f83dc80159 100644
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -18,9 +18,18 @@ import axios from 'axios';
import { Router } from 'express';
import net from 'net';
import validator from 'validator';
+import { z } from 'zod';
const authRoutes = Router();
+export const quickConnectSecret = z.object({
+ secret: z
+ .string()
+ .min(8)
+ .max(128)
+ .regex(/^[A-Fa-f0-9]+$/),
+});
+
authRoutes.get('/me', isAuthenticated(), async (req, res) => {
const userRepository = getRepository(User);
if (!req.user) {
@@ -43,7 +52,18 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
logger.warn(`User ${user.username} has no valid email address`);
}
- return res.status(200).json(user);
+ return res.status(200).json({
+ ...user.toJSON(),
+ settings: user.settings && {
+ locale: user.settings.locale,
+ discoverRegion: user.settings.discoverRegion,
+ streamingRegion: user.settings.streamingRegion,
+ originalLanguage: user.settings.originalLanguage,
+ notificationTypes: user.settings.notificationTypes,
+ watchlistSyncMovies: user.settings.watchlistSyncMovies,
+ watchlistSyncTv: user.settings.watchlistSyncTv,
+ },
+ });
});
authRoutes.post('/plex', async (req, res, next) => {
@@ -534,6 +554,30 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
message: e.errorCode,
});
+ case ApiErrorCode.ConnectionError:
+ logger.error(
+ `Unable to reach the ${
+ settings.main.mediaServerType === MediaServerType.JELLYFIN
+ ? ServerType.JELLYFIN
+ : ServerType.EMBY
+ } server.`,
+ {
+ label: 'Auth',
+ error: e.errorCode,
+ status: e.statusCode,
+ hostname: getHostname({
+ useSsl: body.useSsl,
+ ip: body.hostname,
+ port: body.port,
+ urlBase: body.urlBase,
+ }),
+ }
+ );
+ return next({
+ status: e.statusCode,
+ message: e.errorCode,
+ });
+
case ApiErrorCode.InvalidCredentials:
logger.warn(
'Failed sign-in attempt from user with incorrect Jellyfin credentials',
@@ -593,6 +637,219 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
});
+authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
+ const settings = getSettings();
+
+ if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
+ return next({
+ status: 403,
+ message: 'Quick Connect is only supported by Jellyfin.',
+ });
+ }
+
+ try {
+ const hostname = getHostname();
+ const jellyfinServer = new JellyfinAPI(
+ hostname ?? '',
+ undefined,
+ undefined
+ );
+
+ const response = await jellyfinServer.initiateQuickConnect();
+
+ return res.status(200).json({
+ code: response.Code,
+ secret: response.Secret,
+ });
+ } catch (error) {
+ logger.error('Error initiating Jellyfin quick connect', {
+ label: 'Auth',
+ errorMessage: error.message,
+ });
+ return next({
+ status: 500,
+ message: 'Failed to initiate quick connect.',
+ });
+ }
+});
+
+authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
+ const settings = getSettings();
+
+ if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
+ return next({
+ status: 403,
+ message: 'Quick Connect is only supported by Jellyfin.',
+ });
+ }
+
+ const result = quickConnectSecret.safeParse(req.query);
+ if (!result.success) {
+ return next({
+ status: 400,
+ message: 'Invalid secret format',
+ });
+ }
+
+ const { secret } = result.data;
+
+ try {
+ const hostname = getHostname();
+ const jellyfinServer = new JellyfinAPI(
+ hostname ?? '',
+ undefined,
+ undefined
+ );
+
+ const response = await jellyfinServer.checkQuickConnect(secret);
+
+ return res.status(200).json({ authenticated: response.Authenticated });
+ } catch (e) {
+ return next({
+ status: e.statusCode || 500,
+ message: 'Failed to check Quick Connect status',
+ });
+ }
+});
+
+authRoutes.post(
+ '/jellyfin/quickconnect/authenticate',
+ async (req, res, next) => {
+ const settings = getSettings();
+ const userRepository = getRepository(User);
+ const result = quickConnectSecret.safeParse(req.body);
+ if (!result.success) {
+ return next({
+ status: 400,
+ message: 'Secret required',
+ });
+ }
+
+ const { secret } = result.data;
+
+ if (
+ settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
+ !(await userRepository.count())
+ ) {
+ return next({
+ status: 403,
+ message: 'Quick Connect is not available during initial setup.',
+ });
+ }
+
+ if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
+ return next({
+ status: 403,
+ message: 'Quick Connect is only supported by Jellyfin.',
+ });
+ }
+
+ try {
+ const hostname = getHostname();
+ const jellyfinServer = new JellyfinAPI(
+ hostname ?? '',
+ undefined,
+ undefined
+ );
+
+ const account = await jellyfinServer.authenticateQuickConnect(secret);
+
+ let user = await userRepository.findOne({
+ where: { jellyfinUserId: account.User.Id },
+ });
+
+ const deviceId = Buffer.from(
+ `BOT_seerr_${account.User.Name ?? ''}`
+ ).toString('base64');
+
+ if (user) {
+ logger.info('Quick Connect sign-in from existing user', {
+ label: 'API',
+ ip: req.ip,
+ jellyfinUsername: account.User.Name,
+ userId: user.id,
+ });
+
+ user.jellyfinAuthToken = account.AccessToken;
+ user.jellyfinDeviceId = deviceId;
+ user.avatar = getUserAvatarUrl(user);
+ await userRepository.save(user);
+ } else if (!settings.main.newPlexLogin) {
+ logger.warn(
+ 'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
+ {
+ label: 'API',
+ ip: req.ip,
+ jellyfinUserId: account.User.Id,
+ jellyfinUsername: account.User.Name,
+ }
+ );
+ return next({
+ status: 403,
+ message: 'Access denied.',
+ });
+ } else {
+ logger.info(
+ 'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
+ {
+ label: 'API',
+ ip: req.ip,
+ jellyfinUsername: account.User.Name,
+ }
+ );
+
+ user = new User({
+ email: account.User.Name,
+ jellyfinUsername: account.User.Name,
+ jellyfinUserId: account.User.Id,
+ jellyfinDeviceId: deviceId,
+ permissions: settings.main.defaultPermissions,
+ userType: UserType.JELLYFIN,
+ });
+ user.avatar = getUserAvatarUrl(user);
+ await userRepository.save(user);
+ }
+
+ if (user.jellyfinUserId) {
+ try {
+ const { changed } = await checkAvatarChanged(user);
+
+ if (changed) {
+ user.avatar = getUserAvatarUrl(user);
+ await userRepository.save(user);
+ logger.debug('Avatar updated during Quick Connect login', {
+ userId: user.id,
+ jellyfinUserId: user.jellyfinUserId,
+ });
+ }
+ } catch (error) {
+ logger.error('Error handling avatar during Quick Connect login', {
+ label: 'Auth',
+ errorMessage: error.message,
+ });
+ }
+ }
+
+ // Set session
+ if (req.session) {
+ req.session.userId = user.id;
+ }
+
+ return res.status(200).json(user?.filter() ?? {});
+ } catch (e) {
+ logger.error('Quick Connect authentication failed', {
+ label: 'Auth',
+ error: e.message,
+ ip: req.ip,
+ });
+ return next({
+ status: e.statusCode || 500,
+ message: ApiErrorCode.InvalidCredentials,
+ });
+ }
+ }
+);
+
authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
@@ -671,7 +928,7 @@ authRoutes.post('/logout', async (req, res, next) => {
await axios.delete(`${baseUrl}/Devices`, {
params: { Id: user.jellyfinDeviceId },
headers: {
- 'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="seerr", Version="${
+ Authorization: `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="seerr", Version="${
settings.main.mediaServerType === MediaServerType.EMBY
? '1.0.0'
: getAppVersion()
diff --git a/server/routes/author.ts b/server/routes/author.ts
new file mode 100644
index 0000000000..807481c4c7
--- /dev/null
+++ b/server/routes/author.ts
@@ -0,0 +1,60 @@
+import Hardcover from '@server/api/hardcover';
+import type { HardcoverAuthorDetails } from '@server/api/hardcover/interfaces';
+import { MediaType } from '@server/constants/media';
+import Media from '@server/entity/Media';
+import { getSettings } from '@server/lib/settings';
+import logger from '@server/logger';
+import { mapAuthorDetails } from '@server/models/Author';
+import { mapBookResult } from '@server/models/Search';
+import { Router } from 'express';
+
+const authorRoutes = Router();
+
+authorRoutes.get('/:id', async (req, res, next) => {
+ if (!getSettings().main.hardcoverapikey) {
+ return next({
+ status: 503,
+ message: 'Hardcover API key is not configured.',
+ });
+ }
+
+ const hardcover = new Hardcover();
+
+ try {
+ const hardcoverAuthor = (await hardcover.getAuthor(
+ Number(req.params.id)
+ )) as HardcoverAuthorDetails;
+
+ const media = await Media.getRelatedMedia(
+ req.user,
+ hardcoverAuthor.contributions.map((result) => ({
+ tmdbId: result.book.id,
+ mediaType: MediaType.BOOK,
+ }))
+ );
+
+ const books = hardcoverAuthor.contributions.map((result) =>
+ mapBookResult(
+ result.book,
+ media.find(
+ (req) =>
+ req.tmdbId === result.book.id && req.mediaType === MediaType.BOOK
+ )
+ )
+ );
+
+ return res.status(200).json(mapAuthorDetails(hardcoverAuthor, books));
+ } catch (e) {
+ logger.debug('Something went wrong retrieving author', {
+ label: 'API',
+ errorMessage: e.message,
+ authorId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve author.',
+ });
+ }
+});
+
+export default authorRoutes;
diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts
index 2027d79dbf..c61522a516 100644
--- a/server/routes/avatarproxy.ts
+++ b/server/routes/avatarproxy.ts
@@ -4,7 +4,6 @@ import { User } from '@server/entity/User';
import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
-import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import axios from 'axios';
import { Router } from 'express';
@@ -15,25 +14,9 @@ const router = Router();
let _avatarImageProxy: ImageProxy | null = null;
-async function initAvatarImageProxy() {
+function initAvatarImageProxy() {
if (!_avatarImageProxy) {
- const userRepository = getRepository(User);
- const admin = await userRepository.findOne({
- where: { id: 1 },
- select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
- order: { id: 'ASC' },
- });
- const deviceId = admin?.jellyfinDeviceId || 'BOT_seerr';
- const authToken = getSettings().jellyfin.apiKey;
- _avatarImageProxy = new ImageProxy('avatar', '', {
- headers: {
- 'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${deviceId}", Version="${
- getSettings().main.mediaServerType === MediaServerType.EMBY
- ? '1.0.0'
- : getAppVersion()
- }", Token="${authToken}"`,
- },
- });
+ _avatarImageProxy = new ImageProxy('avatar', '');
}
return _avatarImageProxy;
}
@@ -88,7 +71,7 @@ export async function checkAvatarChanged(
return { changed: false, etag: user.avatarETag ?? undefined };
}
- const avatarImageCache = await initAvatarImageProxy();
+ const avatarImageCache = initAvatarImageProxy();
await avatarImageCache.clearCachedImage(jellyfinAvatarUrl);
const imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
@@ -115,20 +98,18 @@ export async function checkAvatarChanged(
}
}
-router.get('/:jellyfinUserId', async (req, res) => {
+router.get('/:jellyfinUserId', async (req, res, next) => {
+ if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
+ const mediaServerType = getSettings().main.mediaServerType;
+ return next({
+ status: 400,
+ message: `Provided URL is not ${
+ mediaServerType === MediaServerType.JELLYFIN ? 'a Jellyfin' : 'an Emby'
+ } avatar.`,
+ });
+ }
try {
- if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
- const mediaServerType = getSettings().main.mediaServerType;
- throw new Error(
- `Provided URL is not ${
- mediaServerType === MediaServerType.JELLYFIN
- ? 'a Jellyfin'
- : 'an Emby'
- } avatar.`
- );
- }
-
- const avatarImageCache = await initAvatarImageProxy();
+ const avatarImageCache = initAvatarImageProxy();
const userEtag = req.headers['if-none-match'];
@@ -173,9 +154,14 @@ router.get('/:jellyfinUserId', async (req, res) => {
res.end(imageData.imageBuffer);
} catch (e) {
- logger.error('Failed to proxy avatar image', {
- errorMessage: e.message,
- });
+ logger.error('Failed to proxy avatar image', { errorMessage: e.message });
+ if (!res.headersSent) {
+ return next({
+ status: 500,
+ message: 'Failed to proxy avatar image.',
+ });
+ }
+ next(e);
}
});
diff --git a/server/routes/blocklist.test.ts b/server/routes/blocklist.test.ts
new file mode 100644
index 0000000000..9273783eff
--- /dev/null
+++ b/server/routes/blocklist.test.ts
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+
+import { MediaStatus, MediaType } from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import { Blocklist } from '@server/entity/Blocklist';
+import Media from '@server/entity/Media';
+import { getSettings } from '@server/lib/settings';
+import { checkUser } from '@server/middleware/auth';
+import { setupTestDb } from '@server/test/db';
+import {
+ assertNoCredentials,
+ seedUserSettings,
+} from '@server/test/userSettings';
+import type { Express } from 'express';
+import express from 'express';
+import session from 'express-session';
+import request from 'supertest';
+import authRoutes from './auth';
+import blocklistRoutes from './blocklist';
+
+let app: Express;
+
+function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(
+ session({
+ secret: 'test-secret',
+ resave: false,
+ saveUninitialized: false,
+ })
+ );
+ app.use(checkUser);
+ app.use('/auth', authRoutes);
+ app.use('/blocklist', blocklistRoutes);
+ app.use(
+ (
+ err: { status?: number; message?: string },
+ _req: express.Request,
+ res: express.Response,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _next: express.NextFunction
+ ) => {
+ res
+ .status(err.status ?? 500)
+ .json({ status: err.status ?? 500, message: err.message });
+ }
+ );
+ return app;
+}
+
+before(async () => {
+ app = createApp();
+});
+
+setupTestDb();
+
+async function loginAs(email: string, password: string) {
+ const settings = getSettings();
+ const priorLocalLogin = settings.main.localLogin;
+ settings.main.localLogin = true;
+
+ try {
+ const agent = request.agent(app);
+ const res = await agent.post('/auth/local').send({ email, password });
+ assert.strictEqual(res.status, 200);
+ return agent;
+ } finally {
+ settings.main.localLogin = priorLocalLogin;
+ }
+}
+
+describe('GET /blocklist/:id', () => {
+ // this route replies with res.send() rather than res.json()
+ it('omits notification settings from the blocklisting user', async () => {
+ const blocklistUser = await seedUserSettings('demo@seerr.dev');
+
+ const media = await getRepository(Media).save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 12345,
+ status: MediaStatus.BLOCKLISTED,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+
+ await getRepository(Blocklist).save(
+ new Blocklist({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 12345,
+ title: 'Blocked Movie',
+ user: blocklistUser,
+ media,
+ })
+ );
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.get('/blocklist/12345?mediaType=movie');
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.user.email, 'demo@seerr.dev');
+ assert.ok(!('settings' in res.body.user));
+ assertNoCredentials(res.body);
+ });
+});
diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts
index 39315e8d22..77ea001f8d 100644
--- a/server/routes/blocklist.ts
+++ b/server/routes/blocklist.ts
@@ -92,7 +92,11 @@ blocklistRoutes.get(
}),
async (req, res, next) => {
const mediaType = req.query.mediaType;
- if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) {
+ if (
+ mediaType !== MediaType.MOVIE &&
+ mediaType !== MediaType.TV &&
+ mediaType !== MediaType.BOOK
+ ) {
return next({
status: 400,
message: 'Invalid or missing mediaType query parameter.',
@@ -268,7 +272,11 @@ blocklistRoutes.delete(
}),
async (req, res, next) => {
const mediaType = req.query.mediaType;
- if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) {
+ if (
+ mediaType !== MediaType.MOVIE &&
+ mediaType !== MediaType.TV &&
+ mediaType !== MediaType.BOOK
+ ) {
return next({
status: 400,
message: 'Invalid or missing mediaType query parameter.',
diff --git a/server/routes/book.ts b/server/routes/book.ts
new file mode 100644
index 0000000000..e961c58838
--- /dev/null
+++ b/server/routes/book.ts
@@ -0,0 +1,40 @@
+import Hardcover from '@server/api/hardcover';
+import { MediaType } from '@server/constants/media';
+import Media from '@server/entity/Media';
+import { getSettings } from '@server/lib/settings';
+import logger from '@server/logger';
+import { mapBookDetails } from '@server/models/Book';
+import { Router } from 'express';
+
+const bookRoutes = Router();
+
+bookRoutes.get('/:id', async (req, res, next) => {
+ if (!getSettings().main.hardcoverapikey) {
+ return next({
+ status: 503,
+ message: 'Hardcover API key is not configured.',
+ });
+ }
+
+ const hardcover = new Hardcover();
+
+ try {
+ const hardcoverBook = await hardcover.getBook(Number(req.params.id));
+
+ const media = await Media.getMedia(hardcoverBook.id, MediaType.BOOK);
+
+ return res.status(200).json(mapBookDetails(hardcoverBook, media));
+ } catch (e) {
+ logger.debug('Something went wrong retrieving book', {
+ label: 'API',
+ errorMessage: e.message,
+ bookId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve book.',
+ });
+ }
+});
+
+export default bookRoutes;
diff --git a/server/routes/collection.ts b/server/routes/collection.ts
index f2a6032278..0dc461974d 100644
--- a/server/routes/collection.ts
+++ b/server/routes/collection.ts
@@ -21,7 +21,8 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
collection.parts.map((part) => ({
tmdbId: part.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json(mapCollection(collection, media));
diff --git a/server/routes/discover.ts b/server/routes/discover.ts
index 7f250e6a6e..3260c9284b 100644
--- a/server/routes/discover.ts
+++ b/server/routes/discover.ts
@@ -1,6 +1,9 @@
+import Hardcover, { BookSortOptionsIterable } from '@server/api/hardcover';
import PlexTvAPI from '@server/api/plextv';
-import type { SortOptions } from '@server/api/themoviedb';
-import TheMovieDb from '@server/api/themoviedb';
+import TheMovieDb, {
+ MovieSortOptionsIterable,
+ TvSortOptionsIterable,
+} from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
@@ -15,6 +18,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
+ mapBookResult,
mapCollectionResult,
mapMovieResult,
mapPersonResult,
@@ -62,7 +66,6 @@ const discoverRoutes = Router();
const QueryFilterOptions = z.object({
page: z.coerce.string().optional(),
- sortBy: z.coerce.string().optional(),
primaryReleaseDateGte: z.coerce.string().optional(),
primaryReleaseDateLte: z.coerce.string().optional(),
firstAirDateGte: z.coerce.string().optional(),
@@ -90,21 +93,88 @@ const QueryFilterOptions = z.object({
});
export type FilterOptions = z.infer;
-const ApiQuerySchema = QueryFilterOptions.omit({
+const MovieApiQuerySchema = QueryFilterOptions.omit({
certificationMode: true,
+}).extend({
+ sortBy: z.enum(MovieSortOptionsIterable).optional().catch(undefined),
+});
+const TvApiQuerySchema = QueryFilterOptions.omit({
+ certificationMode: true,
+}).extend({
+ sortBy: z.enum(TvSortOptionsIterable).optional().catch(undefined),
+});
+const BookApiQuerySchema = QueryFilterOptions.omit({
+ certificationMode: true,
+}).extend({
+ sortBy: z.enum(BookSortOptionsIterable).optional().catch(undefined),
+});
+
+discoverRoutes.get('/books', async (req, res, next) => {
+ if (!getSettings().main.hardcoverapikey) {
+ return next({
+ status: 503,
+ message: 'Hardcover API key is not configured.',
+ });
+ }
+
+ const hardcover = new Hardcover();
+
+ try {
+ const query = BookApiQuerySchema.parse(req.query);
+ const data = await hardcover.getDiscoverBooks({
+ page: Number(query.page),
+ genres: query.genre ? query.genre.split(',') : undefined,
+ releaseDateGte: query.primaryReleaseDateGte,
+ releaseDateLte: query.primaryReleaseDateLte,
+ sortBy: query.sortBy,
+ });
+
+ const media = await Media.getRelatedMedia(
+ req.user,
+ data.data.books.map((result) => ({
+ tmdbId: result.id,
+ mediaType: MediaType.BOOK,
+ }))
+ );
+
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ keywords: undefined,
+ results: data.data.books.map((result) =>
+ mapBookResult(
+ result,
+ media.find(
+ (req) =>
+ req.tmdbId === result.id && req.mediaType === MediaType.BOOK
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving popular books', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve popular books.',
+ });
+ }
});
discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
- const query = ApiQuerySchema.parse(req.query);
+ const query = MovieApiQuerySchema.parse(req.query);
const keywords = query.keywords;
const excludeKeywords = query.excludeKeywords;
const data = await tmdb.getDiscoverMovies({
page: Number(query.page),
- sortBy: query.sortBy as SortOptions,
+ sortBy: query.sortBy,
language: req.locale ?? query.language,
originalLanguage: query.language,
genre: query.genre,
@@ -136,7 +206,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
let keywordData: TmdbKeyword[] = [];
@@ -208,7 +279,8 @@ discoverRoutes.get<{ language: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -269,7 +341,8 @@ discoverRoutes.get<{ genreId: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -320,7 +393,8 @@ discoverRoutes.get<{ studioId: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -373,7 +447,8 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -406,12 +481,12 @@ discoverRoutes.get('/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
- const query = ApiQuerySchema.parse(req.query);
+ const query = TvApiQuerySchema.parse(req.query);
const keywords = query.keywords;
const excludeKeywords = query.excludeKeywords;
const data = await tmdb.getDiscoverTv({
page: Number(query.page),
- sortBy: query.sortBy as SortOptions,
+ sortBy: query.sortBy,
language: req.locale ?? query.language,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
@@ -444,7 +519,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
let keywordData: TmdbKeyword[] = [];
@@ -515,7 +591,8 @@ discoverRoutes.get<{ language: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -576,7 +653,8 @@ discoverRoutes.get<{ genreId: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -627,7 +705,8 @@ discoverRoutes.get<{ networkId: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -680,7 +759,8 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -753,7 +833,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
data.results.map((result) => ({
tmdbId: result.id,
mediaType: isMovie(result) ? MediaType.MOVIE : MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -800,7 +881,8 @@ discoverRoutes.get<{ keywordId: string }>(
data.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
diff --git a/server/routes/imageproxy.ts b/server/routes/imageproxy.ts
index 30e3581708..2c8abdd86a 100644
--- a/server/routes/imageproxy.ts
+++ b/server/routes/imageproxy.ts
@@ -1,6 +1,15 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
+import sharp from 'sharp';
+
+const hardcoverTransform = async (buffer: Buffer) => ({
+ buffer: await sharp(buffer)
+ .resize(600, 900, { fit: 'cover' })
+ .webp({ quality: 80 })
+ .toBuffer(),
+ extension: 'webp',
+});
const router = Router();
@@ -29,16 +38,33 @@ function initTvdbImageProxy() {
}
return _tvdbImageProxy;
}
+let _hardcoverImageProxy: ImageProxy;
+function initHardcoverImageProxy() {
+ if (!_hardcoverImageProxy) {
+ _hardcoverImageProxy = new ImageProxy(
+ 'hardcover',
+ 'https://assets.hardcover.app',
+ {
+ rateLimitOptions: {
+ maxRequests: 20,
+ maxRPS: 50,
+ },
+ transform: hardcoverTransform,
+ }
+ );
+ }
+ return _hardcoverImageProxy;
+}
router.get<{
type: string;
path: string[];
-}>('/:type/*path', async (req, res) => {
+}>('/:type/*path', async (req, res, next) => {
const imagePath = '/' + req.params.path.join('/');
if (imagePath.startsWith('//') || imagePath.includes('://')) {
logger.error('Invalid URL for image proxy', { imagePath });
- return res.status(403).send('Invalid URL for image proxy');
+ return next({ status: 403, message: 'Invalid URL for image proxy.' });
}
try {
@@ -47,13 +73,14 @@ router.get<{
imageData = await initTmdbImageProxy().getImage(imagePath);
} else if (req.params.type === 'tvdb') {
imageData = await initTvdbImageProxy().getImage(imagePath);
+ } else if (req.params.type === 'hardcover') {
+ imageData = await initHardcoverImageProxy().getImage(imagePath);
} else {
logger.error('Unsupported image type', {
imagePath,
type: req.params.type,
});
- res.status(400).send('Unsupported image type');
- return;
+ return next({ status: 400, message: 'Unsupported image type.' });
}
res.writeHead(200, {
@@ -70,7 +97,10 @@ router.get<{
imagePath,
errorMessage: e.message,
});
- res.status(500).send();
+ if (!res.headersSent) {
+ return next({ status: 500, message: 'Failed to proxy image.' });
+ }
+ next(e);
}
});
diff --git a/server/routes/index.ts b/server/routes/index.ts
index f701acf968..6399f5135b 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -29,7 +29,9 @@ import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import authRoutes from './auth';
+import authorRoutes from './author';
import blocklistRoutes from './blocklist';
+import bookRoutes from './book';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
@@ -39,6 +41,7 @@ import movieRoutes from './movie';
import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
+import seriesRoutes from './series';
import serviceRoutes from './service';
import tvRoutes from './tv';
import user from './user';
@@ -48,40 +51,47 @@ const router = Router();
router.use(checkUser);
router.get('/status', async (req, res) => {
- const githubApi = new GithubAPI();
-
+ const settings = getSettings();
const currentVersion = getAppVersion();
const commitTag = getCommitTag();
+ const checkUpdate =
+ req.query.checkUpdateAvailable !== undefined
+ ? req.query.checkUpdateAvailable
+ : settings.fullPublicSettings.versionCheck;
let updateAvailable = false;
let commitsBehind = 0;
- if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
- const commits = await githubApi.getSeerrCommits();
+ if (checkUpdate) {
+ const githubApi = new GithubAPI();
- if (commits.length) {
- const filteredCommits = commits.filter(
- (commit) => !commit.commit.message.includes('[skip ci]')
- );
- if (filteredCommits[0].sha !== commitTag) {
- updateAvailable = true;
- }
+ if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
+ const commits = await githubApi.getSeerrCommits();
- const commitIndex = filteredCommits.findIndex(
- (commit) => commit.sha === commitTag
- );
+ if (commits.length) {
+ const filteredCommits = commits.filter(
+ (commit) => !commit.commit.message.includes('[skip ci]')
+ );
+ if (filteredCommits[0].sha !== commitTag) {
+ updateAvailable = true;
+ }
- if (updateAvailable) {
- commitsBehind = commitIndex;
+ const commitIndex = filteredCommits.findIndex(
+ (commit) => commit.sha === commitTag
+ );
+
+ if (updateAvailable) {
+ commitsBehind = commitIndex;
+ }
}
- }
- } else if (commitTag !== 'local') {
- const releases = await githubApi.getSeerrReleases();
+ } else if (commitTag !== 'local') {
+ const releases = await githubApi.getSeerrReleases();
- if (releases.length) {
- const latestVersion = releases[0];
+ if (releases.length) {
+ const latestVersion = releases[0];
- if (!latestVersion.name.includes(currentVersion)) {
- updateAvailable = true;
+ if (!latestVersion.name.includes(currentVersion)) {
+ updateAvailable = true;
+ }
}
}
}
@@ -89,8 +99,7 @@ router.get('/status', async (req, res) => {
return res.status(200).json({
version: getAppVersion(),
commitTag: getCommitTag(),
- updateAvailable,
- commitsBehind,
+ ...(checkUpdate && { updateAvailable, commitsBehind }),
restartRequired: restartFlag.isSet(),
});
});
@@ -166,17 +175,16 @@ router.use(
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);
+router.use('/book', isAuthenticated(), bookRoutes);
+router.use('/author', isAuthenticated(), authorRoutes);
+router.use('/series', isAuthenticated(), seriesRoutes);
router.use('/person', isAuthenticated(), personRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
-router.use(
- '/overrideRule',
- isAuthenticated(Permission.ADMIN),
- overrideRuleRoutes
-);
+router.use('/overrideRule', isAuthenticated(), overrideRuleRoutes);
router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
diff --git a/server/routes/issue.test.ts b/server/routes/issue.test.ts
new file mode 100644
index 0000000000..cd98770e7a
--- /dev/null
+++ b/server/routes/issue.test.ts
@@ -0,0 +1,250 @@
+import assert from 'node:assert/strict';
+import { before, beforeEach, describe, it, mock } from 'node:test';
+
+import { IssueType } from '@server/constants/issue';
+import { MediaStatus, MediaType } from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import Issue from '@server/entity/Issue';
+import Media from '@server/entity/Media';
+import { User } from '@server/entity/User';
+import { Permission } from '@server/lib/permissions';
+import { getSettings } from '@server/lib/settings';
+import { checkUser } from '@server/middleware/auth';
+import { IssueCommentSubscriber } from '@server/subscriber/IssueCommentSubscriber';
+import { IssueSubscriber } from '@server/subscriber/IssueSubscriber';
+import { setupTestDb } from '@server/test/db';
+import {
+ assertNoCredentials,
+ seedUserSettings,
+} from '@server/test/userSettings';
+import type { Express } from 'express';
+import express from 'express';
+import session from 'express-session';
+import request from 'supertest';
+import authRoutes from './auth';
+import issueRoutes from './issue';
+import issueCommentRoutes from './issueComment';
+
+const sendIssueNotificationMock = mock.method(
+ IssueSubscriber.prototype as unknown as {
+ sendIssueNotification: (...args: unknown[]) => Promise;
+ },
+ 'sendIssueNotification',
+ async () => undefined
+).mock;
+
+mock.method(
+ IssueCommentSubscriber.prototype as unknown as {
+ sendIssueCommentNotification: (...args: unknown[]) => Promise;
+ },
+ 'sendIssueCommentNotification',
+ async () => undefined
+);
+
+let app: Express;
+
+function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(
+ session({
+ secret: 'test-secret',
+ resave: false,
+ saveUninitialized: false,
+ })
+ );
+ app.use(checkUser);
+ app.use('/auth', authRoutes);
+ app.use('/issue', issueRoutes);
+ app.use('/issueComment', issueCommentRoutes);
+ app.use(
+ (
+ err: { status?: number; message?: string },
+ _req: express.Request,
+ res: express.Response,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _next: express.NextFunction
+ ) => {
+ res
+ .status(err.status ?? 500)
+ .json({ status: err.status ?? 500, message: err.message });
+ }
+ );
+ return app;
+}
+
+before(async () => {
+ app = createApp();
+});
+
+beforeEach(() => {
+ sendIssueNotificationMock.resetCalls();
+});
+
+setupTestDb();
+
+async function loginAs(email: string, password: string) {
+ const settings = getSettings();
+ const priorLocalLogin = settings.main.localLogin;
+ settings.main.localLogin = true;
+
+ try {
+ const agent = request.agent(app);
+ const res = await agent.post('/auth/local').send({ email, password });
+ assert.strictEqual(res.status, 200);
+ return agent;
+ } finally {
+ settings.main.localLogin = priorLocalLogin;
+ }
+}
+
+async function seedMedia() {
+ return getRepository(Media).save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 12345,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+}
+
+describe('POST /issue', () => {
+ it('creates an issue on behalf of the supplied userId', async () => {
+ const issueRepo = getRepository(Issue);
+ const userRepo = getRepository(User);
+ const media = await seedMedia();
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.post('/issue').send({
+ issueType: IssueType.VIDEO,
+ message: 'Playback stutters near the end.',
+ mediaId: media.id,
+ problemSeason: 0,
+ problemEpisode: 0,
+ userId: friend.id,
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.createdBy.email, 'demo@seerr.dev');
+ assert.strictEqual(res.body.comments[0].user.email, 'demo@seerr.dev');
+
+ const persisted = await issueRepo.findOneOrFail({
+ where: { id: res.body.id },
+ });
+
+ assert.strictEqual(persisted.createdBy.id, friend.id);
+ assert.strictEqual(persisted.comments[0].user.id, friend.id);
+ });
+
+ it('defaults to the authenticated user when userId is omitted', async () => {
+ const media = await seedMedia();
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.post('/issue').send({
+ issueType: IssueType.AUDIO,
+ message: 'Audio is out of sync.',
+ mediaId: media.id,
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.createdBy.email, 'admin@seerr.dev');
+ assert.strictEqual(res.body.comments[0].user.email, 'admin@seerr.dev');
+ });
+
+ it('allows creators to supply their own userId', async () => {
+ const userRepo = getRepository(User);
+ const media = await seedMedia();
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ friend.permissions = Permission.CREATE_ISSUES;
+ await userRepo.save(friend);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/issue').send({
+ issueType: IssueType.SUBTITLES,
+ message: 'Subtitles are missing.',
+ mediaId: media.id,
+ userId: friend.id,
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.createdBy.email, 'demo@seerr.dev');
+ assert.strictEqual(res.body.comments[0].user.email, 'demo@seerr.dev');
+ });
+
+ it('prevents non-managers from supplying another userId', async () => {
+ const userRepo = getRepository(User);
+ const media = await seedMedia();
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ friend.permissions = Permission.CREATE_ISSUES;
+ await userRepo.save(friend);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/issue').send({
+ issueType: IssueType.OTHER,
+ message: 'Something else is wrong.',
+ mediaId: media.id,
+ userId: admin.id,
+ });
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(
+ res.body.message,
+ 'You do not have permission to create an issue on behalf of another user.'
+ );
+ });
+
+ it('returns 404 when the supplied userId does not exist', async () => {
+ const media = await seedMedia();
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.post('/issue').send({
+ issueType: IssueType.OTHER,
+ message: 'Something else is wrong.',
+ mediaId: media.id,
+ userId: 999999,
+ });
+
+ assert.strictEqual(res.status, 404);
+ assert.strictEqual(res.body.message, 'Issue user not found');
+ });
+});
+
+describe('GET /issueComment/:commentId', () => {
+ it('omits notification settings from the comment author', async () => {
+ const userRepo = getRepository(User);
+ const media = await seedMedia();
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+ await seedUserSettings('demo@seerr.dev');
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const created = await agent.post('/issue').send({
+ issueType: IssueType.VIDEO,
+ message: 'Playback stutters near the end.',
+ mediaId: media.id,
+ userId: friend.id,
+ });
+ assert.strictEqual(created.status, 201);
+
+ const res = await agent.get(`/issueComment/${created.body.comments[0].id}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.user.email, 'demo@seerr.dev');
+ assert.ok(!('settings' in res.body.user));
+ assertNoCredentials(res.body);
+ });
+});
diff --git a/server/routes/issue.ts b/server/routes/issue.ts
index 1e8769f8ec..8719c00716 100644
--- a/server/routes/issue.ts
+++ b/server/routes/issue.ts
@@ -3,7 +3,11 @@ import { getRepository } from '@server/datasource';
import Issue from '@server/entity/Issue';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
-import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
+import { User } from '@server/entity/User';
+import type {
+ IssueRequestBody,
+ IssueResultsResponse,
+} from '@server/interfaces/api/issueInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@@ -95,17 +99,7 @@ issueRoutes.get, IssueResultsResponse>(
}
);
-issueRoutes.post<
- Record,
- Issue,
- {
- message: string;
- mediaId: number;
- issueType: number;
- problemSeason: number;
- problemEpisode: number;
- }
->(
+issueRoutes.post, Issue, IssueRequestBody>(
'/',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
@@ -118,6 +112,7 @@ issueRoutes.post<
const issueRepository = getRepository(Issue);
const mediaRepository = getRepository(Media);
+ const userRepository = getRepository(User);
const media = await mediaRepository.findOne({
where: { id: req.body.mediaId },
@@ -127,15 +122,37 @@ issueRoutes.post<
return next({ status: 404, message: 'Media does not exist.' });
}
+ let createdBy = req.user;
+
+ if (req.body.userId != null && req.body.userId !== req.user.id) {
+ if (!req.user.hasPermission(Permission.MANAGE_ISSUES)) {
+ return next({
+ status: 403,
+ message:
+ 'You do not have permission to create an issue on behalf of another user.',
+ });
+ }
+
+ const user = await userRepository.findOne({
+ where: { id: req.body.userId },
+ });
+
+ if (!user) {
+ return next({ status: 404, message: 'Issue user not found' });
+ }
+
+ createdBy = user;
+ }
+
const issue = new Issue({
- createdBy: req.user,
+ createdBy,
issueType: req.body.issueType,
problemSeason: req.body.problemSeason,
problemEpisode: req.body.problemEpisode,
media,
comments: [
new IssueComment({
- user: req.user,
+ user: createdBy,
message: req.body.message,
}),
],
@@ -143,7 +160,7 @@ issueRoutes.post<
const newIssue = await issueRepository.save(issue);
- return res.status(200).json(newIssue);
+ return res.status(201).json(newIssue);
}
);
diff --git a/server/routes/media.ts b/server/routes/media.ts
index de3c1b6dc3..3ddfd15d5c 100644
--- a/server/routes/media.ts
+++ b/server/routes/media.ts
@@ -1,4 +1,5 @@
import RadarrAPI from '@server/api/servarr/radarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TautulliAPI from '@server/api/tautulli';
import TheMovieDb from '@server/api/themoviedb';
@@ -17,10 +18,40 @@ import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import type { FindOneOptions } from 'typeorm';
-import { In, IsNull, Not } from 'typeorm';
+import { EntityNotFoundError, In, IsNull, Not } from 'typeorm';
const mediaRoutes = Router();
+mediaRoutes.get(
+ '/lookup/:id',
+ isAuthenticated(Permission.MANAGE_REQUESTS),
+ async (req, res, next) => {
+ const mediaType = req.query.mediaType;
+ if (
+ mediaType !== MediaType.MOVIE &&
+ mediaType !== MediaType.TV &&
+ mediaType !== MediaType.BOOK
+ ) {
+ return next({
+ status: 400,
+ message: 'Invalid or missing mediaType query parameter.',
+ });
+ }
+
+ const mediaRepository = getRepository(Media);
+
+ try {
+ const media = await mediaRepository.findOne({
+ where: { tmdbId: Number(req.params.id), mediaType },
+ });
+
+ return res.status(200).json({ id: media?.id || null });
+ } catch (e) {
+ next({ status: 500, message: e.message });
+ }
+ }
+);
+
mediaRoutes.get('/', async (req, res, next) => {
const mediaRepository = getRepository(Media);
@@ -68,10 +99,14 @@ mediaRoutes.get('/', async (req, res, next) => {
let whereClause: FindOneOptions['where'];
if (statusFilter || req.query.sort === 'mediaAdded') {
- whereClause = {};
- if (statusFilter) whereClause.status = statusFilter;
- if (req.query.sort === 'mediaAdded')
- whereClause.mediaAddedAt = Not(IsNull());
+ const mediaAddedClause =
+ req.query.sort === 'mediaAdded' ? { mediaAddedAt: Not(IsNull()) } : {};
+ whereClause = statusFilter
+ ? [
+ { ...mediaAddedClause, status: statusFilter },
+ { ...mediaAddedClause, status4k: statusFilter },
+ ]
+ : mediaAddedClause;
}
try {
@@ -187,11 +222,15 @@ mediaRoutes.delete(
return res.status(204).send();
} catch (e) {
- logger.error('Something went wrong fetching media in delete request', {
+ if (e instanceof EntityNotFoundError) {
+ return res.status(204).send();
+ }
+ logger.error('Something went wrong deleting media', {
label: 'Media',
+ mediaId: req.params.id,
message: e.message,
});
- next({ status: 404, message: 'Media not found' });
+ next({ status: 500, message: 'Failed to delete media' });
}
}
);
@@ -209,12 +248,17 @@ mediaRoutes.delete(
const is4k = String(req.query.is4k) === 'true';
const isMovie = media.mediaType === MediaType.MOVIE;
+ const isBook = media.mediaType === MediaType.BOOK;
let serviceSettings;
if (isMovie) {
serviceSettings = settings.radarr.find(
(radarr) => radarr.isDefault && radarr.is4k === is4k
);
+ } else if (isBook) {
+ serviceSettings = settings.readarr.find(
+ (readarr) => readarr.isDefault && readarr.is4k === is4k
+ );
} else {
serviceSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
@@ -231,6 +275,10 @@ mediaRoutes.delete(
serviceSettings = settings.radarr.find(
(radarr) => radarr.id === specificServiceId
);
+ } else if (isBook) {
+ serviceSettings = settings.readarr.find(
+ (readarr) => readarr.id === specificServiceId
+ );
} else {
serviceSettings = settings.sonarr.find(
(sonarr) => sonarr.id === specificServiceId
@@ -238,19 +286,22 @@ mediaRoutes.delete(
}
}
+ const serviceName = isMovie ? 'Radarr' : isBook ? 'Readarr' : 'Sonarr';
+ const serviceType = is4k ? (isBook ? 'Audiobook' : '4K ') : '';
+
if (!serviceSettings) {
- logger.warn(
- `There is no default ${
- is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
- }/ server configured. Did you set any of your ${
- is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
- } servers as default?`,
+ const arrName = serviceType + serviceName;
+ logger.info(
+ `There is no default ${arrName} server configured. Did you set any of your ${arrName} servers as default?`,
{
label: 'Media Request',
mediaId: media.id,
}
);
- return;
+ return next({
+ status: 409,
+ message: `No ${arrName} server configured to delete media files`,
+ });
}
let service;
@@ -259,6 +310,11 @@ mediaRoutes.delete(
apiKey: serviceSettings?.apiKey,
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
});
+ } else if (isBook) {
+ service = new ReadarrAPI({
+ apiKey: serviceSettings?.apiKey,
+ url: ReadarrAPI.buildUrl(serviceSettings, '/api/v1'),
+ });
} else {
service = new SonarrAPI({
apiKey: serviceSettings?.apiKey,
@@ -268,6 +324,8 @@ mediaRoutes.delete(
if (isMovie) {
await (service as RadarrAPI).removeMovie(media.tmdbId);
+ } else if (isBook) {
+ await (service as ReadarrAPI).removeBook(media.tmdbId);
} else {
const tmdb = new TheMovieDb();
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
@@ -276,15 +334,27 @@ mediaRoutes.delete(
throw new Error('TVDB ID not found');
}
await (service as SonarrAPI).removeSeries(tvdbId);
+
+ for (const season of media.seasons) {
+ season[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
+ }
}
+ media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
+ media.resetServiceData(is4k);
+ await mediaRepository.save(media);
+
return res.status(204).send();
} catch (e) {
- logger.error('Something went wrong fetching media in delete request', {
+ if (e instanceof EntityNotFoundError) {
+ return next({ status: 404, message: 'Media not found' });
+ }
+ logger.error('Something went wrong deleting media file', {
label: 'Media',
+ mediaId: req.params.id,
message: e.message,
});
- next({ status: 404, message: 'Media not found' });
+ next({ status: 500, message: 'Failed to delete media file' });
}
}
);
diff --git a/server/routes/mediaInfo.test.ts b/server/routes/mediaInfo.test.ts
new file mode 100644
index 0000000000..1c42456e19
--- /dev/null
+++ b/server/routes/mediaInfo.test.ts
@@ -0,0 +1,273 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+
+import TheMovieDb from '@server/api/themoviedb';
+import type {
+ TmdbMovieDetails,
+ TmdbTvDetails,
+} from '@server/api/themoviedb/interfaces';
+import { IssueType } from '@server/constants/issue';
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import Issue from '@server/entity/Issue';
+import IssueComment from '@server/entity/IssueComment';
+import Media from '@server/entity/Media';
+import { MediaRequest } from '@server/entity/MediaRequest';
+import { User } from '@server/entity/User';
+import { Permission } from '@server/lib/permissions';
+import { getSettings } from '@server/lib/settings';
+import { checkUser, isAuthenticated } from '@server/middleware/auth';
+import { setupTestDb } from '@server/test/db';
+import type { Express } from 'express';
+import express from 'express';
+import session from 'express-session';
+import request from 'supertest';
+import authRoutes from './auth';
+import movieRoutes from './movie';
+import tvRoutes from './tv';
+
+const OWNER_EMAIL = 'owner@seerr.dev';
+const OWNER_PLEX_ID = 918273;
+const COMMENT_BODY = 'Only visible to issue viewers';
+
+Object.defineProperty(TheMovieDb.prototype, 'getMovie', {
+ get() {
+ return async ({ movieId }: { movieId: number }) =>
+ ({
+ id: movieId,
+ genres: [],
+ original_language: 'en',
+ overview: 'overview',
+ keywords: { keywords: [] },
+ external_ids: {},
+ release_dates: { results: [] },
+ credits: { cast: [], crew: [] },
+ production_companies: [],
+ production_countries: [],
+ spoken_languages: [],
+ videos: { results: [] },
+ }) as unknown as TmdbMovieDetails;
+ },
+ set() {},
+ configurable: true,
+});
+
+Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
+ get() {
+ return async ({ tvId }: { tvId: number }) =>
+ ({
+ id: tvId,
+ genres: [],
+ original_language: 'en',
+ overview: 'overview',
+ keywords: { results: [] },
+ external_ids: {},
+ content_ratings: { results: [] },
+ credits: { cast: [], crew: [] },
+ aggregate_credits: { cast: [], crew: [] },
+ seasons: [],
+ networks: [],
+ production_companies: [],
+ production_countries: [],
+ spoken_languages: [],
+ origin_country: [],
+ episode_run_time: [],
+ created_by: [],
+ videos: { results: [] },
+ }) as unknown as TmdbTvDetails;
+ },
+ set() {},
+ configurable: true,
+});
+
+let app: Express;
+
+function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(
+ session({
+ secret: 'test-secret',
+ resave: false,
+ saveUninitialized: false,
+ })
+ );
+ app.use(checkUser);
+ app.use('/auth', authRoutes);
+ app.use('/movie', isAuthenticated(), movieRoutes);
+ app.use('/tv', isAuthenticated(), tvRoutes);
+ app.use(
+ (
+ err: { status?: number; message?: string },
+ _req: express.Request,
+ res: express.Response,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _next: express.NextFunction
+ ) => {
+ res
+ .status(err.status ?? 500)
+ .json({ status: err.status ?? 500, message: err.message });
+ }
+ );
+ return app;
+}
+
+before(async () => {
+ app = createApp();
+});
+
+setupTestDb();
+
+async function loginAs(email: string, password: string) {
+ const settings = getSettings();
+ const priorLocalLogin = settings.main.localLogin;
+ settings.main.localLogin = true;
+
+ try {
+ const agent = request.agent(app);
+ const res = await agent.post('/auth/local').send({ email, password });
+ assert.strictEqual(res.status, 200);
+ return agent;
+ } finally {
+ settings.main.localLogin = priorLocalLogin;
+ }
+}
+
+async function setViewerPermissions(permissions: number) {
+ const userRepository = getRepository(User);
+ const viewer = await userRepository.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+ viewer.permissions = permissions;
+ await userRepository.save(viewer);
+ return viewer;
+}
+
+async function seedMedia(mediaType: MediaType, tmdbId: number) {
+ const userRepository = getRepository(User);
+ const owner = await userRepository.findOneOrFail({ where: { id: 1 } });
+ owner.email = OWNER_EMAIL;
+ owner.plexId = OWNER_PLEX_ID;
+ await userRepository.save(owner);
+
+ const media = await getRepository(Media).save(
+ new Media({
+ mediaType,
+ tmdbId,
+ status: MediaStatus.AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+
+ await getRepository(MediaRequest).save(
+ new MediaRequest({
+ type: mediaType,
+ media,
+ requestedBy: owner,
+ modifiedBy: owner,
+ status: MediaRequestStatus.APPROVED,
+ is4k: false,
+ })
+ );
+
+ const issue = await getRepository(Issue).save(
+ new Issue({
+ issueType: IssueType.VIDEO,
+ media,
+ createdBy: owner,
+ problemSeason: 2,
+ problemEpisode: 5,
+ })
+ );
+
+ await getRepository(IssueComment).save(
+ new IssueComment({ user: owner, issue, message: COMMENT_BODY })
+ );
+
+ return { media, owner };
+}
+
+const routes = [
+ { path: '/movie', tmdbId: 12345, mediaType: MediaType.MOVIE },
+ { path: '/tv', tmdbId: 67890, mediaType: MediaType.TV },
+];
+
+for (const { path, tmdbId, mediaType } of routes) {
+ describe(`GET ${path}/:id mediaInfo`, () => {
+ it('omits the requesting user’s email and plexId', async () => {
+ await seedMedia(mediaType, tmdbId);
+ await setViewerPermissions(Permission.REQUEST);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.get(`${path}/${tmdbId}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.mediaInfo.requests.length, 1);
+
+ const serialized = JSON.stringify(res.body);
+ assert.ok(!serialized.includes(OWNER_EMAIL), 'email was serialized');
+ assert.ok(
+ !serialized.includes(String(OWNER_PLEX_ID)),
+ 'plexId was serialized'
+ );
+ });
+
+ it('keeps the request fields the UI renders', async () => {
+ await seedMedia(mediaType, tmdbId);
+ await setViewerPermissions(Permission.REQUEST);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.get(`${path}/${tmdbId}`);
+
+ const { requestedBy } = res.body.mediaInfo.requests[0];
+ assert.strictEqual(requestedBy.id, 1);
+ assert.strictEqual(typeof requestedBy.displayName, 'string');
+ assert.ok('avatar' in requestedBy);
+ });
+
+ it('hides issues from a viewer without an issue permission', async () => {
+ await seedMedia(mediaType, tmdbId);
+ await setViewerPermissions(Permission.REQUEST);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.get(`${path}/${tmdbId}`);
+
+ assert.deepStrictEqual(res.body.mediaInfo.issues, []);
+ assert.ok(!JSON.stringify(res.body).includes(COMMENT_BODY));
+ });
+
+ for (const permission of [
+ Permission.MANAGE_ISSUES,
+ Permission.VIEW_ISSUES,
+ Permission.CREATE_ISSUES,
+ ]) {
+ it(`returns issues without contents to a viewer with ${Permission[permission]}`, async () => {
+ await seedMedia(mediaType, tmdbId);
+ await setViewerPermissions(Permission.REQUEST | permission);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.get(`${path}/${tmdbId}`);
+
+ assert.strictEqual(res.body.mediaInfo.issues.length, 1);
+
+ const [issue] = res.body.mediaInfo.issues;
+ assert.strictEqual(issue.issueType, IssueType.VIDEO);
+ assert.ok(!('comments' in issue), 'comments were serialized');
+ assert.ok(!('problemSeason' in issue), 'problemSeason was serialized');
+ assert.ok(
+ !('problemEpisode' in issue),
+ 'problemEpisode was serialized'
+ );
+
+ const serialized = JSON.stringify(res.body);
+ assert.ok(!serialized.includes(COMMENT_BODY));
+ assert.ok(!serialized.includes(OWNER_EMAIL));
+ assert.ok(!serialized.includes(String(OWNER_PLEX_ID)));
+ });
+ }
+ });
+}
diff --git a/server/routes/movie.ts b/server/routes/movie.ts
index bd86e447a9..affd2d54e1 100644
--- a/server/routes/movie.ts
+++ b/server/routes/movie.ts
@@ -34,7 +34,11 @@ movieRoutes.get('/:id', async (req, res, next) => {
},
});
- const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
+ const data = mapMovieDetails(
+ tmdbMovie,
+ media?.filter(req.user),
+ onUserWatchlist
+ );
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
@@ -71,7 +75,8 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
results.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -116,7 +121,8 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
results.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.MOVIE,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts
index 912a68aae6..49a550facb 100644
--- a/server/routes/overrideRule.ts
+++ b/server/routes/overrideRule.ts
@@ -1,7 +1,19 @@
+import TheMovieDb from '@server/api/themoviedb';
+import type {
+ TmdbMovieDetails,
+ TmdbTvDetails,
+} from '@server/api/themoviedb/interfaces';
+import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
+import { MediaRequest } from '@server/entity/MediaRequest';
import OverrideRule from '@server/entity/OverrideRule';
+import { User } from '@server/entity/User';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
+import overrideRules, {
+ type OverrideRulesResult,
+} from '@server/lib/overrideRules';
import { Permission } from '@server/lib/permissions';
+import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
@@ -32,10 +44,12 @@ overrideRuleRoutes.post<
language?: string;
keywords?: string;
profileId?: number;
+ metadataProfileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
+ readarrServiceId?: number;
}
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
@@ -47,10 +61,12 @@ overrideRuleRoutes.post<
language: req.body.language,
keywords: req.body.keywords,
profileId: req.body.profileId,
+ metadataProfileId: req.body.metadataProfileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
radarrServiceId: req.body.radarrServiceId,
sonarrServiceId: req.body.sonarrServiceId,
+ readarrServiceId: req.body.readarrServiceId,
});
const newRule = await overrideRuleRepository.save(rule);
@@ -61,6 +77,127 @@ overrideRuleRoutes.post<
}
});
+overrideRuleRoutes.post<
+ Record,
+ OverrideRulesResult,
+ {
+ mediaType: MediaType;
+ is4k: boolean;
+ tmdbId: number;
+ requestUser: number;
+ requestId: number | null;
+ tags?: number[] | null;
+ serviceId?: number;
+ }
+>(
+ '/advancedRequest',
+ isAuthenticated([Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], {
+ type: 'or',
+ }),
+ async (req, res, next) => {
+ try {
+ const tmdb = new TheMovieDb();
+ let tmdbMedia: TmdbMovieDetails | TmdbTvDetails;
+ try {
+ tmdbMedia =
+ req.body.mediaType === MediaType.MOVIE
+ ? await tmdb.getMovie({ movieId: req.body.tmdbId })
+ : await tmdb.getTvShow({ tvId: req.body.tmdbId });
+ } catch {
+ return next({ status: 404, message: 'Media not found' });
+ }
+
+ const userRepository = getRepository(User);
+ const requestRepository = getRepository(MediaRequest);
+
+ let ruleUser: User | null = req.user || null;
+
+ if (req.body.requestId) {
+ const request = await requestRepository.findOne({
+ where: {
+ id: Number(req.body.requestId),
+ },
+ });
+
+ if (!request) {
+ return next({ status: 404, message: 'Request not found.' });
+ }
+
+ if (
+ (request.requestedBy.id !== req.user?.id ||
+ (req.body.mediaType !== MediaType.TV &&
+ !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
+ !req.user?.hasPermission(Permission.MANAGE_REQUESTS)
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to modify this request.',
+ });
+ }
+
+ ruleUser = request.requestedBy;
+
+ if (
+ req.body.requestUser &&
+ req.body.requestUser !== request.requestedBy.id &&
+ !req.user?.hasPermission([
+ Permission.MANAGE_USERS,
+ Permission.MANAGE_REQUESTS,
+ ])
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to modify the request user.',
+ });
+ } else if (req.body.requestUser) {
+ ruleUser = await userRepository.findOneOrFail({
+ where: { id: req.body.requestUser },
+ });
+ }
+ } else if (req.body.requestUser !== req.user?.id) {
+ if (
+ req.body.requestUser &&
+ !req.user?.hasPermission([
+ Permission.MANAGE_USERS,
+ Permission.MANAGE_REQUESTS,
+ ])
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to modify the request user.',
+ });
+ } else if (req.body.requestUser) {
+ ruleUser = await userRepository.findOneOrFail({
+ where: { id: req.body.requestUser },
+ });
+ }
+ }
+
+ if (!ruleUser) {
+ return next({ status: 404, message: 'User not found.' });
+ }
+
+ const overrideRulesResult: OverrideRulesResult = await overrideRules({
+ mediaType: req.body.mediaType,
+ is4k: req.body.is4k,
+ tmdbMedia,
+ requestUser: ruleUser,
+ tags: req.body.tags,
+ serviceId: req.body.serviceId,
+ });
+
+ res.status(200).json(overrideRulesResult);
+ } catch (e) {
+ logger.error('Something went wrong evaluating override rules', {
+ label: 'API',
+ errorMessage: e.message,
+ tmdbId: req.body.tmdbId,
+ });
+ next({ status: 500, message: 'Unable to evaluate override rules.' });
+ }
+ }
+);
+
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
@@ -70,10 +207,12 @@ overrideRuleRoutes.put<
language?: string;
keywords?: string;
profileId?: number;
+ metadataProfileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
+ readarrServiceId?: number;
}
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
@@ -94,10 +233,12 @@ overrideRuleRoutes.put<
rule.language = req.body.language;
rule.keywords = req.body.keywords;
rule.profileId = req.body.profileId;
+ rule.metadataProfileId = req.body.metadataProfileId;
rule.rootFolder = req.body.rootFolder;
rule.tags = req.body.tags;
rule.radarrServiceId = req.body.radarrServiceId;
rule.sonarrServiceId = req.body.sonarrServiceId;
+ rule.readarrServiceId = req.body.readarrServiceId;
const newRule = await overrideRuleRepository.save(rule);
diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts
index 2b45127dd3..6e0f27dc24 100644
--- a/server/routes/request.test.ts
+++ b/server/routes/request.test.ts
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import { before, beforeEach, describe, it, mock } from 'node:test';
+import TheMovieDb from '@server/api/themoviedb';
+import type {
+ TmdbMovieDetails,
+ TmdbTvDetails,
+} from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
MediaStatus,
@@ -9,10 +14,20 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
+import OverrideRule from '@server/entity/OverrideRule';
+import Season from '@server/entity/Season';
+import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
+import { Permission } from '@server/lib/permissions';
+import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { checkUser } from '@server/middleware/auth';
+import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber';
import { setupTestDb } from '@server/test/db';
+import {
+ assertNoCredentials,
+ seedUserSettings,
+} from '@server/test/userSettings';
import type { Express } from 'express';
import express from 'express';
import session from 'express-session';
@@ -26,6 +41,102 @@ const sendNotificationMock = mock.method(
async () => undefined
).mock;
+const getMovieImpl: (args: {
+ movieId: number;
+ language?: string;
+}) => Promise = async ({ movieId }) => fakeTmdbMovie(movieId);
+
+Object.defineProperty(TheMovieDb.prototype, 'getMovie', {
+ get() {
+ return async (args: { movieId: number; language?: string }) =>
+ getMovieImpl(args);
+ },
+ set() {},
+ configurable: true,
+});
+
+const getTvShowImpl: (args: {
+ tvId: number;
+ language?: string;
+}) => Promise = async ({ tvId }) => fakeTmdbShow(tvId);
+
+Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
+ get() {
+ return async (args: { tvId: number; language?: string }) =>
+ getTvShowImpl(args);
+ },
+ set() {},
+ configurable: true,
+});
+
+function fakeTmdbMovie(tmdbId: number): TmdbMovieDetails {
+ return {
+ id: tmdbId,
+ genres: [],
+ original_language: 'en',
+ keywords: { keywords: [] },
+ external_ids: {},
+ } as unknown as TmdbMovieDetails;
+}
+
+function fakeTmdbShow(tmdbId: number): TmdbTvDetails {
+ return {
+ id: tmdbId,
+ genres: [],
+ original_language: 'en',
+ keywords: { results: [] },
+ external_ids: {},
+ } as unknown as TmdbTvDetails;
+}
+
+function configureRadarr(overrides: Partial[]): void {
+ const settings = getSettings();
+ settings.radarr = overrides.map((o, i) => ({
+ id: i,
+ name: `Radarr ${i}`,
+ hostname: 'localhost',
+ port: 7878,
+ apiKey: 'test-key',
+ baseUrl: '',
+ useSsl: false,
+ activeProfileId: 1,
+ activeDirectory: '/movies',
+ is4k: false,
+ minimumAvailability: 'released',
+ tags: [],
+ isDefault: i === 0,
+ syncEnabled: true,
+ preventSearch: false,
+ externalUrl: '',
+ ...o,
+ })) as RadarrSettings[];
+}
+
+function configureSonarr(overrides: Partial[]): void {
+ const settings = getSettings();
+ settings.sonarr = overrides.map((o, i) => ({
+ id: i,
+ name: `Sonarr ${i}`,
+ hostname: 'localhost',
+ port: 8989,
+ apiKey: 'test-key',
+ baseUrl: '',
+ useSsl: false,
+ activeProfileId: 1,
+ activeDirectory: '/tv',
+ activeLanguageProfileId: 1,
+ animeTags: [],
+ is4k: false,
+ enableSeasonFolders: true,
+ tags: [],
+ isDefault: i === 0,
+ syncEnabled: true,
+ preventSearch: false,
+ externalUrl: '',
+ ...o,
+ })) as SonarrSettings[];
+}
+
let app: Express;
function createApp() {
@@ -88,7 +199,7 @@ async function seedRequest(status = MediaRequestStatus.PENDING) {
const requestRepo = getRepository(MediaRequest);
const requestedBy = await userRepo.findOneOrFail({
- where: { email: 'friend@seerr.dev' },
+ where: { email: 'demo@seerr.dev' },
});
const media = await mediaRepo.save(
@@ -121,7 +232,7 @@ describe('DELETE /request/:requestId', () => {
it('allows the owner to delete their own pending request', async () => {
const mediaRequest = await seedRequest();
- const agent = await loginAs('friend@seerr.dev', 'test1234');
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.delete(`/request/${mediaRequest.id}`);
assert.strictEqual(res.status, 204);
@@ -165,7 +276,7 @@ describe('DELETE /request/:requestId', () => {
})
);
- const agent = await loginAs('friend@seerr.dev', 'test1234');
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.delete(`/request/${mediaRequest.id}`);
assert.strictEqual(res.status, 401);
@@ -174,7 +285,7 @@ describe('DELETE /request/:requestId', () => {
it('prevents the owner from deleting an approved request', async () => {
const mediaRequest = await seedRequest(MediaRequestStatus.APPROVED);
- const agent = await loginAs('friend@seerr.dev', 'test1234');
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.delete(`/request/${mediaRequest.id}`);
assert.strictEqual(res.status, 401);
@@ -186,6 +297,20 @@ describe('DELETE /request/:requestId', () => {
assert.strictEqual(res.status, 404);
});
+
+ it('deletes a request once when two deletes race', async () => {
+ const repo = getRepository(MediaRequest);
+ const mediaRequest = await seedRequest();
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const results = await Promise.all([
+ admin.delete(`/request/${mediaRequest.id}`),
+ admin.delete(`/request/${mediaRequest.id}`),
+ ]);
+
+ assert.deepStrictEqual(results.map((r) => r.status).sort(), [204, 404]);
+ assert.strictEqual(await repo.count({ where: { id: mediaRequest.id } }), 0);
+ });
});
describe('PUT /request/:requestId (movie)', () => {
@@ -211,57 +336,1295 @@ describe('PUT /request/:requestId (movie)', () => {
assert.strictEqual(saved.profileId, 7);
assert.strictEqual(saved.rootFolder, '/updated/movies');
});
+
+ it('refuses to modify a request that is no longer pending', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const mediaRequest = await seedRequest(MediaRequestStatus.APPROVED);
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.MOVIE,
+ serverId: 3,
+ rootFolder: '/updated/movies',
+ });
+
+ assert.strictEqual(res.status, 409);
+ assert.match(res.body.message, /pending/i);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.strictEqual(saved.serverId, null);
+ assert.strictEqual(saved.rootFolder, null);
+ });
});
-describe('POST /request/:requestId/:status', () => {
- const cases = [
- { action: 'approve', expected: MediaRequestStatus.APPROVED },
- { action: 'decline', expected: MediaRequestStatus.DECLINED },
- ] as const;
+async function seedUser(
+ email: string,
+ quotas: {
+ movieQuotaLimit?: number;
+ tvQuotaLimit?: number;
+ tvQuotaDays?: number;
+ } = {}
+) {
+ const userRepo = getRepository(User);
+ const user = await userRepo.findOneOrFail({ where: { email } });
+ Object.assign(user, quotas);
- for (const { action, expected } of cases) {
- it(`transitions to ${action}d and records the acting user`, async () => {
- const repo = getRepository(MediaRequest);
- const pending = await seedRequest();
- const admin = await loginAs('admin@seerr.dev', 'test1234');
+ return userRepo.save(user);
+}
- const res = await admin.post(`/request/${pending.id}/${action}`);
+async function seedTvMedia(tmdbId: number) {
+ const mediaRepo = getRepository(Media);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.status, expected);
- assert.strictEqual(res.body.modifiedBy.email, 'admin@seerr.dev');
+ return (
+ (await mediaRepo.findOne({
+ where: { tmdbId, mediaType: MediaType.TV },
+ })) ??
+ (await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.TV,
+ tmdbId,
+ status: MediaStatus.PENDING,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ ))
+ );
+}
- const persisted = await repo.findOneOrFail({
- where: { id: pending.id },
- relations: { modifiedBy: true },
- });
+async function seedMediaSeasons(
+ tmdbId: number,
+ seasons: { seasonNumber: number; status: MediaStatus }[]
+) {
+ const media = await seedTvMedia(tmdbId);
+ media.seasons = seasons.map(
+ ({ seasonNumber, status }) =>
+ new Season({ seasonNumber, status, status4k: MediaStatus.UNKNOWN })
+ );
- assert.strictEqual(persisted.status, expected);
- assert.strictEqual(persisted.modifiedBy?.email, 'admin@seerr.dev');
- assert.ok(persisted.updatedAt > pending.updatedAt);
+ return getRepository(Media).save(media);
+}
+
+async function seedTvRequest(
+ requestedBy: User,
+ seasons: number[],
+ { tmdbId = 67890, ignoreQuota = false, createdAt = new Date() } = {}
+) {
+ return getRepository(MediaRequest).save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.PENDING,
+ media: await seedTvMedia(tmdbId),
+ requestedBy,
+ is4k: false,
+ ignoreQuota,
+ createdAt,
+ seasons: seasons.map(
+ (seasonNumber) =>
+ new SeasonRequest({
+ seasonNumber,
+ status: MediaRequestStatus.PENDING,
+ })
+ ),
+ })
+ );
+}
+
+describe('PUT /request/:requestId (tv)', () => {
+ it('does not add a season held by another request', async () => {
+ const requestRepo = getRepository(MediaRequest);
+
+ const owner = await seedUser('admin@seerr.dev');
+ const otherUser = await seedUser('demo@seerr.dev');
+
+ const mediaRequest = await seedTvRequest(owner, [1, 2]);
+ const otherRequest = await seedTvRequest(otherUser, [3]);
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2, 3],
});
- }
-});
-describe('POST /request/:requestId/retry', () => {
- it('re-approves a failed request and records the acting user', async () => {
- const repo = getRepository(MediaRequest);
- const failed = await seedRequest(MediaRequestStatus.FAILED);
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 2]
+ );
+
+ const otherSaved = await requestRepo.findOneOrFail({
+ where: { id: otherRequest.id },
+ });
+ assert.deepStrictEqual(
+ otherSaved.seasons.map((s) => s.seasonNumber),
+ [3]
+ );
+ });
+
+ it('gives a season to only one of two concurrent edits', async () => {
+ const requestRepo = getRepository(MediaRequest);
+
+ const owner = await seedUser('admin@seerr.dev');
+ const otherUser = await seedUser('demo@seerr.dev');
+
+ const mediaRequest = await seedTvRequest(owner, [1]);
+ const otherRequest = await seedTvRequest(otherUser, [3]);
+
const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const friend = await loginAs('demo@seerr.dev', 'test1234');
- const res = await admin.post(`/request/${failed.id}/retry`);
+ const [adminRes, friendRes] = await Promise.all([
+ admin
+ .put(`/request/${mediaRequest.id}`)
+ .send({ mediaType: MediaType.TV, seasons: [1, 2] }),
+ friend
+ .put(`/request/${otherRequest.id}`)
+ .send({ mediaType: MediaType.TV, seasons: [3, 2] }),
+ ]);
+
+ assert.strictEqual(adminRes.status, 200);
+ assert.strictEqual(friendRes.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ const otherSaved = await requestRepo.findOneOrFail({
+ where: { id: otherRequest.id },
+ });
+
+ const holders = [saved, otherSaved].filter((r) =>
+ r.seasons.some((s) => s.seasonNumber === 2)
+ );
+ assert.strictEqual(holders.length, 1);
+
+ assert.deepStrictEqual(
+ [...saved.seasons, ...otherSaved.seasons]
+ .map((s) => s.seasonNumber)
+ .sort((a, b) => a - b),
+ [1, 2, 3]
+ );
+ });
+});
+
+describe('PUT /request/:requestId (season availability)', () => {
+ it('does not add a season the media already has', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev');
+ const mediaRequest = await seedTvRequest(owner, [1]);
+ await seedMediaSeasons(67890, [
+ { seasonNumber: 2, status: MediaStatus.AVAILABLE },
+ ]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2, 3],
+ });
assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.status, MediaRequestStatus.APPROVED);
- assert.strictEqual(res.body.modifiedBy.email, 'admin@seerr.dev');
- const persisted = await repo.findOneOrFail({
- where: { id: failed.id },
- relations: { modifiedBy: true },
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
});
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 3]
+ );
+ });
- assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED);
- assert.strictEqual(persisted.modifiedBy?.email, 'admin@seerr.dev');
- assert.ok(persisted.updatedAt > failed.updatedAt);
+ it('returns 202 when every requested season is already covered', async () => {
+ const owner = await seedUser('demo@seerr.dev');
+ const mediaRequest = await seedTvRequest(owner, [1]);
+ await seedMediaSeasons(67890, [
+ { seasonNumber: 2, status: MediaStatus.AVAILABLE },
+ ]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [2],
+ });
+
+ assert.strictEqual(res.status, 202);
+ });
+
+ it('keeps the seasons it already holds once they are available', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev');
+ const mediaRequest = await seedTvRequest(owner, [1, 2]);
+ await seedMediaSeasons(67890, [
+ { seasonNumber: 1, status: MediaStatus.AVAILABLE },
+ { seasonNumber: 2, status: MediaStatus.PROCESSING },
+ ]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2],
+ serverId: 3,
+ });
+
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 2]
+ );
+ assert.strictEqual(saved.serverId, 3);
+ });
+
+ it('does not charge quota for a season the media already has', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 1 });
+ const mediaRequest = await seedTvRequest(owner, [1]);
+ await seedMediaSeasons(67890, [
+ { seasonNumber: 2, status: MediaStatus.AVAILABLE },
+ ]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2],
+ });
+
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber),
+ [1]
+ );
+ });
+});
+
+describe('PUT /request/:requestId (quota)', () => {
+ it('rejects adding seasons beyond the season limit', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 2 });
+ const mediaRequest = await seedTvRequest(owner, [1, 2]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2, 3],
+ });
+
+ assert.strictEqual(res.status, 403);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 2]
+ );
+ });
+
+ it('rejects adding seasons to a request older than the quota window', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', {
+ tvQuotaLimit: 2,
+ tvQuotaDays: 7,
+ });
+ const mediaRequest = await seedTvRequest(owner, [1, 2], {
+ createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+ });
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2, 3],
+ });
+
+ assert.strictEqual(res.status, 403);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 2]
+ );
+ });
+
+ it('allows swapping seasons at the season limit', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 2 });
+ const mediaRequest = await seedTvRequest(owner, [1, 2]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [3, 4],
+ });
+
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [3, 4]
+ );
+ });
+
+ it('rejects reassignment to a user without room for the existing seasons', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('admin@seerr.dev');
+ const target = await seedUser('demo@seerr.dev', { tvQuotaLimit: 1 });
+ const mediaRequest = await seedTvRequest(owner, [1, 2]);
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2],
+ userId: target.id,
+ });
+
+ assert.strictEqual(res.status, 403);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.strictEqual(saved.requestedBy.id, owner.id);
+ });
+
+ it('rejects reassignment of a movie request to a user at their limit', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const mediaRepo = getRepository(Media);
+
+ const owner = await seedUser('admin@seerr.dev');
+ const target = await seedUser('demo@seerr.dev', { movieQuotaLimit: 1 });
+
+ // Uses up the target's single movie request
+ await seedRequest();
+
+ const media = await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 55555,
+ status: MediaStatus.PENDING,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+ const mediaRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.PENDING,
+ media,
+ requestedBy: owner,
+ is4k: false,
+ })
+ );
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.MOVIE,
+ userId: target.id,
+ });
+
+ assert.strictEqual(res.status, 403);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.strictEqual(saved.requestedBy.id, owner.id);
+ });
+
+ it('allows reassignment to a user who bypasses quotas', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 1 });
+ const target = await seedUser('admin@seerr.dev');
+ const mediaRequest = await seedTvRequest(owner, [1, 2]);
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2],
+ userId: target.id,
+ });
+
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.strictEqual(saved.requestedBy.id, target.id);
+ });
+
+ it('allows an edit that exceeds the limit when the request ignores quota', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 2 });
+
+ await seedTvRequest(owner, [1, 2], { tmdbId: 77777 });
+ const mediaRequest = await seedTvRequest(owner, [1, 2], {
+ ignoreQuota: true,
+ });
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2, 3],
+ });
+
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 2, 3]
+ );
+ });
+
+ it('charges only the net increase when an edit both adds and removes', async () => {
+ const requestRepo = getRepository(MediaRequest);
+ const owner = await seedUser('demo@seerr.dev', { tvQuotaLimit: 4 });
+ const mediaRequest = await seedTvRequest(owner, [1, 2, 3]);
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.put(`/request/${mediaRequest.id}`).send({
+ mediaType: MediaType.TV,
+ seasons: [1, 2, 4, 5],
+ });
+
+ assert.strictEqual(res.status, 200);
+
+ const saved = await requestRepo.findOneOrFail({
+ where: { id: mediaRequest.id },
+ });
+ assert.deepStrictEqual(
+ saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
+ [1, 2, 4, 5]
+ );
+ });
+});
+
+describe('GET /request/:requestId', () => {
+ it('omits notification settings from requestedBy and modifiedBy', async () => {
+ const pending = await seedRequest();
+ await seedUserSettings('demo@seerr.dev');
+ await seedUserSettings('admin@seerr.dev');
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const approved = await admin.post(`/request/${pending.id}/approve`);
+ assert.strictEqual(approved.status, 200);
+
+ const res = await admin.get(`/request/${pending.id}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.requestedBy.email, 'demo@seerr.dev');
+ assert.strictEqual(res.body.modifiedBy.email, 'admin@seerr.dev');
+ assert.ok(!('settings' in res.body.requestedBy));
+ assert.ok(!('settings' in res.body.modifiedBy));
+ assertNoCredentials(res.body);
+ });
+});
+
+describe('POST /request/:requestId/:status', () => {
+ const cases = [
+ { action: 'approve', expected: MediaRequestStatus.APPROVED },
+ { action: 'decline', expected: MediaRequestStatus.DECLINED },
+ ] as const;
+
+ for (const { action, expected } of cases) {
+ it(`transitions to ${action}d and records the acting user`, async () => {
+ const repo = getRepository(MediaRequest);
+ const pending = await seedRequest();
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const res = await admin.post(`/request/${pending.id}/${action}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.status, expected);
+ assert.strictEqual(res.body.modifiedBy.email, 'admin@seerr.dev');
+
+ const persisted = await repo.findOneOrFail({
+ where: { id: pending.id },
+ relations: { modifiedBy: true },
+ });
+
+ assert.strictEqual(persisted.status, expected);
+ assert.strictEqual(persisted.modifiedBy?.email, 'admin@seerr.dev');
+ assert.ok(persisted.updatedAt > pending.updatedAt);
+ });
+ }
+
+ it('rejects a status the route does not define', async () => {
+ const repo = getRepository(MediaRequest);
+ const pending = await seedRequest();
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const res = await admin.post(`/request/${pending.id}/frobnicate`);
+
+ assert.strictEqual(res.status, 400);
+ assert.match(res.body.message, /approve/i);
+
+ const persisted = await repo.findOneOrFail({
+ where: { id: pending.id },
+ relations: { modifiedBy: true },
+ });
+ assert.strictEqual(persisted.status, MediaRequestStatus.PENDING);
+ assert.ok(!persisted.modifiedBy);
+ });
+
+ it('refuses to act on a request that is no longer pending', async () => {
+ const repo = getRepository(MediaRequest);
+ const approved = await seedRequest(MediaRequestStatus.APPROVED);
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const res = await admin.post(`/request/${approved.id}/decline`);
+
+ assert.strictEqual(res.status, 409);
+ assert.match(res.body.message, /pending/i);
+
+ const persisted = await repo.findOneOrFail({ where: { id: approved.id } });
+ assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED);
+ });
+
+ it('applies only one of a concurrent approve and decline', async () => {
+ const repo = getRepository(MediaRequest);
+ const pending = await seedRequest();
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const [approve, decline] = await Promise.all([
+ admin.post(`/request/${pending.id}/approve`),
+ admin.post(`/request/${pending.id}/decline`),
+ ]);
+
+ assert.deepStrictEqual([approve.status, decline.status].sort(), [200, 409]);
+
+ const persisted = await repo.findOneOrFail({ where: { id: pending.id } });
+ assert.strictEqual(
+ persisted.status,
+ approve.status === 200
+ ? MediaRequestStatus.APPROVED
+ : MediaRequestStatus.DECLINED
+ );
+ });
+
+ it('rejects the removed pending verb even on a non-pending request', async () => {
+ const repo = getRepository(MediaRequest);
+ const declined = await seedRequest(MediaRequestStatus.DECLINED);
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const res = await admin.post(`/request/${declined.id}/pending`);
+
+ assert.strictEqual(res.status, 400);
+ assert.match(res.body.message, /approve/i);
+
+ const persisted = await repo.findOneOrFail({ where: { id: declined.id } });
+ assert.strictEqual(persisted.status, MediaRequestStatus.DECLINED);
+ });
+});
+
+describe('POST /request/:requestId/retry', () => {
+ it('re-approves a failed request and records the acting user', async () => {
+ const repo = getRepository(MediaRequest);
+ const failed = await seedRequest(MediaRequestStatus.FAILED);
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const res = await admin.post(`/request/${failed.id}/retry`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.status, MediaRequestStatus.APPROVED);
+ assert.strictEqual(res.body.modifiedBy.email, 'admin@seerr.dev');
+
+ const persisted = await repo.findOneOrFail({
+ where: { id: failed.id },
+ relations: { modifiedBy: true },
+ });
+
+ assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED);
+ assert.strictEqual(persisted.modifiedBy?.email, 'admin@seerr.dev');
+ assert.ok(persisted.updatedAt > failed.updatedAt);
+ });
+
+ it('refuses to retry a request that has not failed', async () => {
+ const repo = getRepository(MediaRequest);
+ const pending = await seedRequest();
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const res = await admin.post(`/request/${pending.id}/retry`);
+
+ assert.strictEqual(res.status, 409);
+ assert.match(res.body.message, /failed/i);
+
+ const persisted = await repo.findOneOrFail({ where: { id: pending.id } });
+ assert.strictEqual(persisted.status, MediaRequestStatus.PENDING);
+ });
+
+ it('sends a concurrently retried request to *arr once', async (t) => {
+ const repo = getRepository(MediaRequest);
+ const failed = await seedRequest(MediaRequestStatus.FAILED);
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+
+ const sendToRadarr = t.mock.method(
+ MediaRequestSubscriber.prototype,
+ 'sendToRadarr',
+ async () => undefined
+ );
+
+ const results = await Promise.all([
+ admin.post(`/request/${failed.id}/retry`),
+ admin.post(`/request/${failed.id}/retry`),
+ ]);
+
+ assert.deepStrictEqual(results.map((r) => r.status).sort(), [200, 409]);
+ assert.strictEqual(sendToRadarr.mock.callCount(), 1);
+
+ const persisted = await repo.findOneOrFail({ where: { id: failed.id } });
+ assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED);
+ });
+});
+
+describe('DELETE /request/:requestId, deleted media status restoration', () => {
+ async function seedDeletedMediaScenario() {
+ const userRepo = getRepository(User);
+ const mediaRepo = getRepository(Media);
+ const requestRepo = getRepository(MediaRequest);
+
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ const media = await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 99001,
+ status: MediaStatus.DELETED,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+
+ const staleRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.COMPLETED,
+ media,
+ requestedBy: admin,
+ is4k: false,
+ isAutoRequest: true,
+ })
+ );
+
+ media.status = MediaStatus.PENDING;
+ await mediaRepo.save(media);
+
+ const newRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy: admin,
+ is4k: false,
+ })
+ );
+
+ return { media, staleRequest, newRequest, admin };
+ }
+
+ it('restores media status to DELETED when the re-request is deleted and a stale completed request remains', async () => {
+ const mediaRepo = getRepository(Media);
+ const { media, newRequest } = await seedDeletedMediaScenario();
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.delete(`/request/${newRequest.id}`);
+
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.status, MediaStatus.DELETED);
+ });
+
+ it('restores media status4k to DELETED when the re-request is deleted and a stale completed request remains', async () => {
+ const userRepo = getRepository(User);
+ const mediaRepo = getRepository(Media);
+ const requestRepo = getRepository(MediaRequest);
+
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ const media = await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 99003,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.DELETED,
+ })
+ );
+
+ await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.COMPLETED,
+ media,
+ requestedBy: admin,
+ is4k: true,
+ isAutoRequest: true,
+ })
+ );
+
+ media.status4k = MediaStatus.PENDING;
+ await mediaRepo.save(media);
+
+ const newRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy: admin,
+ is4k: true,
+ })
+ );
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.delete(`/request/${newRequest.id}`);
+
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.status4k, MediaStatus.DELETED);
+ });
+
+ it('resets media status to UNKNOWN when the stale completed request is also deleted', async () => {
+ const mediaRepo = getRepository(Media);
+ const requestRepo = getRepository(MediaRequest);
+ const { media, newRequest, staleRequest } =
+ await seedDeletedMediaScenario();
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+
+ await agent.delete(`/request/${newRequest.id}`);
+
+ const res = await agent.delete(`/request/${staleRequest.id}`);
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.status, MediaStatus.UNKNOWN);
+
+ const remaining = await requestRepo.find({
+ where: { media: { id: media.id } },
+ });
+ assert.strictEqual(remaining.length, 0);
+ });
+
+ it('resets media status4k to UNKNOWN when the stale completed 4K request is also deleted', async () => {
+ const userRepo = getRepository(User);
+ const mediaRepo = getRepository(Media);
+ const requestRepo = getRepository(MediaRequest);
+
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ const media = await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 99004,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.DELETED,
+ })
+ );
+
+ const staleRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.COMPLETED,
+ media,
+ requestedBy: admin,
+ is4k: true,
+ isAutoRequest: true,
+ })
+ );
+
+ media.status4k = MediaStatus.PENDING;
+ await mediaRepo.save(media);
+
+ const newRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy: admin,
+ is4k: true,
+ })
+ );
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+
+ await agent.delete(`/request/${newRequest.id}`);
+
+ const res = await agent.delete(`/request/${staleRequest.id}`);
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.status4k, MediaStatus.UNKNOWN);
+ });
+
+ it('does not reset media status when other active requests still exist', async () => {
+ const userRepo = getRepository(User);
+ const mediaRepo = getRepository(Media);
+ const requestRepo = getRepository(MediaRequest);
+
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ const media = await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 99002,
+ status: MediaStatus.PENDING,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+
+ const req1 = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.PENDING,
+ media,
+ requestedBy: admin,
+ is4k: false,
+ })
+ );
+
+ await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.PENDING,
+ media,
+ requestedBy: admin,
+ is4k: false,
+ })
+ );
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.delete(`/request/${req1.id}`);
+
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.status, MediaStatus.PENDING);
+ });
+
+ it('does not reset media status when status is PARTIALLY_AVAILABLE and only completed requests remain', async () => {
+ const userRepo = getRepository(User);
+ const mediaRepo = getRepository(Media);
+ const requestRepo = getRepository(MediaRequest);
+
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ const media = await mediaRepo.save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 99005,
+ status: MediaStatus.PARTIALLY_AVAILABLE,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+
+ const completedRequest = await requestRepo.save(
+ new MediaRequest({
+ type: MediaType.MOVIE,
+ status: MediaRequestStatus.COMPLETED,
+ media,
+ requestedBy: admin,
+ is4k: false,
+ })
+ );
+
+ const agent = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await agent.delete(`/request/${completedRequest.id}`);
+
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.status, MediaStatus.PARTIALLY_AVAILABLE);
+ });
+});
+
+describe('POST /request (movie), override rules', () => {
+ it('applies an override rule when the default Radarr server id differs from its array index', async () => {
+ configureRadarr([{ id: 5, isDefault: true, is4k: false }]);
+ getSettings().sonarr = [];
+
+ const userRepo = getRepository(User);
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const overrideRuleRepo = getRepository(OverrideRule);
+ await overrideRuleRepo.save(
+ new OverrideRule({
+ radarrServiceId: 5,
+ users: String(friend.id),
+ rootFolder: '/overridden/movies',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/request').send({
+ mediaType: MediaType.MOVIE,
+ mediaId: 88001,
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/overridden/movies');
+ });
+
+ it('applies an override rule when the default Radarr server id matches its array index (sanity check)', async () => {
+ configureRadarr([{ id: 0, isDefault: true, is4k: false }]);
+ getSettings().sonarr = [];
+
+ const userRepo = getRepository(User);
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const overrideRuleRepo = getRepository(OverrideRule);
+ await overrideRuleRepo.save(
+ new OverrideRule({
+ radarrServiceId: 0,
+ users: String(friend.id),
+ rootFolder: '/overridden/movies',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/request').send({
+ mediaType: MediaType.MOVIE,
+ mediaId: 88002,
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/overridden/movies');
+ });
+
+ it('does not apply an unrelated override rule when there is no default Radarr server configured', async () => {
+ getSettings().radarr = [];
+ getSettings().sonarr = [];
+
+ const userRepo = getRepository(User);
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const overrideRuleRepo = getRepository(OverrideRule);
+ await overrideRuleRepo.save(
+ new OverrideRule({
+ radarrServiceId: 999,
+ users: String(friend.id),
+ rootFolder: '/overridden/movies',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/request').send({
+ mediaType: MediaType.MOVIE,
+ mediaId: 88005,
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, null);
+ });
+});
+
+describe('POST /request (tv), override rules', () => {
+ it('applies an override rule when the default Sonarr server id differs from its array index', async () => {
+ configureSonarr([{ id: 5, isDefault: true, is4k: false }]);
+ getSettings().radarr = [];
+
+ const userRepo = getRepository(User);
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const overrideRuleRepo = getRepository(OverrideRule);
+ await overrideRuleRepo.save(
+ new OverrideRule({
+ sonarrServiceId: 5,
+ users: String(friend.id),
+ rootFolder: '/overridden/tv',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/request').send({
+ mediaType: MediaType.TV,
+ mediaId: 88003,
+ seasons: [1],
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/overridden/tv');
+ });
+
+ it('applies an override rule when the default Sonarr server id matches its array index (sanity check)', async () => {
+ configureSonarr([{ id: 0, isDefault: true, is4k: false }]);
+ getSettings().radarr = [];
+
+ const userRepo = getRepository(User);
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const overrideRuleRepo = getRepository(OverrideRule);
+ await overrideRuleRepo.save(
+ new OverrideRule({
+ sonarrServiceId: 0,
+ users: String(friend.id),
+ rootFolder: '/overridden/tv',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/request').send({
+ mediaType: MediaType.TV,
+ mediaId: 88004,
+ seasons: [1],
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/overridden/tv');
+ });
+
+ it('does not apply an unrelated override rule when there is no default Sonarr server configured', async () => {
+ getSettings().radarr = [];
+ getSettings().sonarr = [];
+
+ const userRepo = getRepository(User);
+ const friend = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+
+ const overrideRuleRepo = getRepository(OverrideRule);
+ await overrideRuleRepo.save(
+ new OverrideRule({
+ sonarrServiceId: 999,
+ users: String(friend.id),
+ rootFolder: '/overridden/tv',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.post('/request').send({
+ mediaType: MediaType.TV,
+ mediaId: 88006,
+ seasons: [1],
+ });
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, null);
+ });
+});
+
+describe('POST /request, override rules and requester choices', () => {
+ async function requestWithRule(permissions: number) {
+ configureRadarr([{ id: 1, isDefault: true, is4k: false }]);
+ getSettings().sonarr = [];
+
+ const userRepo = getRepository(User);
+ const requester = await userRepo.findOneOrFail({
+ where: { email: 'demo@seerr.dev' },
+ });
+ requester.permissions = permissions;
+ await userRepo.save(requester);
+
+ await getRepository(OverrideRule).save(
+ new OverrideRule({
+ radarrServiceId: 1,
+ rootFolder: '/rule',
+ profileId: 7,
+ tags: '2',
+ })
+ );
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ return agent.post('/request').send({
+ mediaType: MediaType.MOVIE,
+ mediaId: 88010,
+ serverId: 1,
+ rootFolder: '/chosen',
+ tags: [],
+ });
+ }
+
+ it('keeps what an advanced requester sets and fills the rest from the rule', async () => {
+ const res = await requestWithRule(
+ Permission.REQUEST | Permission.REQUEST_ADVANCED
+ );
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/chosen');
+ assert.strictEqual(res.body.profileId, 7);
+ assert.deepStrictEqual(res.body.tags, []);
+ });
+
+ it('keeps what a request manager sets and fills the rest from the rule', async (t) => {
+ // Manage Requests auto-approves; keep it from calling Radarr
+ t.mock.method(
+ MediaRequestSubscriber.prototype,
+ 'sendToRadarr',
+ async () => undefined
+ );
+ const res = await requestWithRule(
+ Permission.REQUEST | Permission.MANAGE_REQUESTS
+ );
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/chosen');
+ assert.strictEqual(res.body.profileId, 7);
+ assert.deepStrictEqual(res.body.tags, []);
+ });
+
+ it('applies the rule over what a regular requester sets', async () => {
+ const res = await requestWithRule(Permission.REQUEST);
+
+ assert.strictEqual(res.status, 201);
+ assert.strictEqual(res.body.rootFolder, '/rule');
+ });
+});
+
+describe('DELETE /request/:requestId, orphaned season status reset', () => {
+ async function seedTvShow(
+ tmdbId: number,
+ seasons: Partial[]
+ ): Promise {
+ const mediaRepo = getRepository(Media);
+
+ return mediaRepo.save(
+ new Media({
+ mediaType: MediaType.TV,
+ tmdbId,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.UNKNOWN,
+ seasons: seasons.map((season) => new Season(season)),
+ })
+ );
+ }
+
+ async function seedTvRequest(
+ media: Media,
+ seasonNumbers: number[]
+ ): Promise {
+ const userRepo = getRepository(User);
+ const requestRepo = getRepository(MediaRequest);
+
+ const admin = await userRepo.findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+
+ return requestRepo.save(
+ new MediaRequest({
+ type: MediaType.TV,
+ status: MediaRequestStatus.APPROVED,
+ media,
+ requestedBy: admin,
+ is4k: false,
+ seasons: seasonNumbers.map(
+ (seasonNumber) =>
+ new SeasonRequest({
+ seasonNumber,
+ status: MediaRequestStatus.APPROVED,
+ })
+ ),
+ })
+ );
+ }
+
+ it('resets a request-covered PROCESSING season to UNKNOWN so it can be re-requested', async () => {
+ const mediaRepo = getRepository(Media);
+ const media = await seedTvShow(99101, [
+ { seasonNumber: 1, status: MediaStatus.PROCESSING },
+ ]);
+ const tvRequest = await seedTvRequest(media, [1]);
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.delete(`/request/${tvRequest.id}`);
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({
+ where: { id: media.id },
+ });
+ assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN);
+
+ const friend = await loginAs('demo@seerr.dev', 'test1234');
+ const reRequest = await friend.post('/request').send({
+ mediaType: MediaType.TV,
+ mediaId: 99101,
+ seasons: [1],
+ });
+ assert.strictEqual(reRequest.status, 201);
+ });
+
+ it('does not touch PROCESSING seasons the deleted request did not cover', async () => {
+ const mediaRepo = getRepository(Media);
+ const media = await seedTvShow(99102, [
+ { seasonNumber: 1, status: MediaStatus.PROCESSING },
+ { seasonNumber: 2, status: MediaStatus.PROCESSING },
+ ]);
+ const tvRequest = await seedTvRequest(media, [1]);
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.delete(`/request/${tvRequest.id}`);
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ const seasonOne = updated.seasons.find((s) => s.seasonNumber === 1);
+ const seasonTwo = updated.seasons.find((s) => s.seasonNumber === 2);
+ assert.strictEqual(seasonOne?.status, MediaStatus.UNKNOWN);
+ assert.strictEqual(seasonTwo?.status, MediaStatus.PROCESSING);
+ });
+
+ it('keeps a season PROCESSING while another active request still covers it', async () => {
+ const mediaRepo = getRepository(Media);
+ const media = await seedTvShow(99103, [
+ { seasonNumber: 1, status: MediaStatus.PROCESSING },
+ ]);
+ const firstRequest = await seedTvRequest(media, [1]);
+ await seedTvRequest(media, [1]);
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.delete(`/request/${firstRequest.id}`);
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.seasons[0].status, MediaStatus.PROCESSING);
+ });
+
+ it('leaves season status4k untouched when deleting a non-4K request', async () => {
+ const mediaRepo = getRepository(Media);
+ const media = await seedTvShow(99104, [
+ {
+ seasonNumber: 1,
+ status: MediaStatus.PROCESSING,
+ status4k: MediaStatus.PROCESSING,
+ },
+ ]);
+ const tvRequest = await seedTvRequest(media, [1]);
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.delete(`/request/${tvRequest.id}`);
+ assert.strictEqual(res.status, 204);
+
+ const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } });
+ assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN);
+ assert.strictEqual(updated.seasons[0].status4k, MediaStatus.PROCESSING);
});
});
diff --git a/server/routes/request.ts b/server/routes/request.ts
index fafa90692e..50315cce4c 100644
--- a/server/routes/request.ts
+++ b/server/routes/request.ts
@@ -1,4 +1,5 @@
import RadarrAPI from '@server/api/servarr/radarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import {
MediaRequestStatus,
@@ -25,6 +26,12 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
+import requestLock, {
+ mediaKey,
+ mediaLock,
+ requestKey,
+ userKey,
+} from '@server/utils/requestLock';
import { Router } from 'express';
const requestRoutes = Router();
@@ -173,6 +180,11 @@ requestRoutes.get, RequestResultsResponse>(
type: MediaType.TV,
});
break;
+ case 'book':
+ query = query.andWhere('request.type = :type', {
+ type: MediaType.BOOK,
+ });
+ break;
}
const [requests, requestCount] = await query
@@ -213,6 +225,24 @@ requestRoutes.get, RequestResultsResponse>(
})
);
+ // get all quality profiles for every configured readarr server
+ const readarrServers = await Promise.all(
+ settings.readarr.map(async (readarrSetting) => {
+ const readarr = new ReadarrAPI({
+ apiKey: readarrSetting.apiKey,
+ url: ReadarrAPI.buildUrl(readarrSetting, '/api/v1'),
+ });
+
+ return {
+ id: readarrSetting.id,
+ profiles: await readarr.getProfiles().catch(() => undefined),
+ metadataProfiles: await readarr
+ .getMetadataProfiles()
+ .catch(() => undefined),
+ };
+ })
+ );
+
// add profile names to the media requests, with undefined if not found
let mappedRequests = requests.map((r) => {
switch (r.type) {
@@ -234,6 +264,20 @@ requestRoutes.get, RequestResultsResponse>(
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
};
}
+ case MediaType.BOOK: {
+ return {
+ ...r,
+ profileName: readarrServers
+ .find((serverr) => serverr.id === r.serverId)
+ ?.profiles?.find((profile) => profile.id === r.profileId)?.name,
+ metadataProfileName: readarrServers
+ .find((serverr) => serverr.id === r.serverId)
+ ?.metadataProfiles?.find(
+ (metadataProfile) =>
+ metadataProfile.id === r.metadataProfileId
+ )?.name,
+ };
+ }
}
});
@@ -263,6 +307,23 @@ requestRoutes.get, RequestResultsResponse>(
),
};
}
+ case MediaType.BOOK: {
+ return {
+ ...r,
+ // check if the readarr server for this request is configured
+ canRemove: readarrServers.some(
+ (server) =>
+ server.id ===
+ (r.is4k ? r.media.serviceId4k : r.media.serviceId)
+ ),
+ };
+ }
+ default: {
+ return {
+ ...r,
+ canRemove: false,
+ };
+ }
}
});
}
@@ -462,136 +523,235 @@ requestRoutes.put<{ requestId: string }>(
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
+ const requestId = Number(req.params.requestId);
try {
- const request = await requestRepository.findOne({
- where: {
- id: Number(req.params.requestId),
- },
- });
-
- if (!request) {
- return next({ status: 404, message: 'Request not found.' });
- }
-
- if (
- (request.requestedBy.id !== req.user?.id ||
- (req.body.mediaType !== 'tv' &&
- !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
- !req.user?.hasPermission(Permission.MANAGE_REQUESTS)
- ) {
- return next({
- status: 403,
- message: 'You do not have permission to modify this request.',
- });
- }
-
- let requestUser = request.requestedBy;
-
- if (
- req.body.userId &&
- req.body.userId !== request.requestedBy.id &&
- !req.user?.hasPermission([
- Permission.MANAGE_USERS,
- Permission.MANAGE_REQUESTS,
- ])
- ) {
- return next({
- status: 403,
- message: 'You do not have permission to modify the request user.',
- });
- } else if (req.body.userId) {
- requestUser = await userRepository.findOneOrFail({
- where: { id: req.body.userId },
+ // Ordering is request then owner here, user then media on create, so no cycle
+ return await requestLock.dispatch(requestKey(requestId), async () => {
+ const request = await requestRepository.findOne({
+ where: {
+ id: requestId,
+ },
});
- }
-
- if (req.body.mediaType === MediaType.MOVIE) {
- request.serverId = req.body.serverId;
- request.profileId = req.body.profileId;
- request.rootFolder = req.body.rootFolder;
- request.tags = req.body.tags;
- request.requestedBy = requestUser as User;
- await requestRepository.save(request);
- } else if (req.body.mediaType === MediaType.TV) {
- const mediaRepository = getRepository(Media);
- request.serverId = req.body.serverId;
- request.profileId = req.body.profileId;
- request.rootFolder = req.body.rootFolder;
- request.languageProfileId = req.body.languageProfileId;
- request.tags = req.body.tags;
- request.requestedBy = requestUser as User;
-
- const requestedSeasons = req.body.seasons as number[] | undefined;
-
- if (!requestedSeasons || requestedSeasons.length === 0) {
- throw new Error(
- 'Missing seasons. If you want to cancel a series request, use the DELETE method.'
- );
+ if (!request) {
+ return next({ status: 404, message: 'Request not found.' });
}
- // Get existing media so we can work with all the requests
- const media = await mediaRepository.findOneOrFail({
- where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV },
- relations: { requests: true },
- });
-
- // Get all requested seasons that are not part of this request we are editing
- const existingSeasons = media.requests
- .filter(
- (r) =>
- r.is4k === request.is4k &&
- r.id !== request.id &&
- r.status !== MediaRequestStatus.DECLINED &&
- r.status !== MediaRequestStatus.COMPLETED
- )
- .reduce((seasons, r) => {
- const combinedSeasons = r.seasons.map(
- (season) => season.seasonNumber
- );
-
- return [...seasons, ...combinedSeasons];
- }, [] as number[]);
-
- const filteredSeasons = requestedSeasons.filter(
- (rs) => !existingSeasons.includes(rs)
- );
-
- if (filteredSeasons.length === 0) {
+ if (
+ (request.requestedBy.id !== req.user?.id ||
+ (req.body.mediaType !== 'tv' &&
+ !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
+ !req.user?.hasPermission(Permission.MANAGE_REQUESTS)
+ ) {
return next({
- status: 202,
- message: 'No seasons available to request',
+ status: 403,
+ message: 'You do not have permission to modify this request.',
});
}
- const newSeasons = requestedSeasons.filter(
- (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
- );
-
- request.seasons = request.seasons.filter((rs) =>
- filteredSeasons.includes(rs.seasonNumber)
- );
+ if (request.status !== MediaRequestStatus.PENDING) {
+ return next({
+ status: 409,
+ message: 'Only pending requests can be modified.',
+ });
+ }
- if (newSeasons.length > 0) {
- logger.debug('Adding new seasons to request', {
- label: 'Media Request',
- newSeasons,
+ const previousOwnerId = request.requestedBy.id;
+ let requestUser = request.requestedBy;
+
+ if (
+ req.body.userId &&
+ req.body.userId !== request.requestedBy.id &&
+ !req.user?.hasPermission([
+ Permission.MANAGE_USERS,
+ Permission.MANAGE_REQUESTS,
+ ])
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to modify the request user.',
+ });
+ } else if (req.body.userId) {
+ requestUser = await userRepository.findOneOrFail({
+ where: { id: req.body.userId },
});
- request.seasons.push(
- ...newSeasons.map(
- (ns) =>
- new SeasonRequest({
- seasonNumber: ns,
- status: MediaRequestStatus.PENDING,
- })
- )
- );
}
- await requestRepository.save(request);
- }
+ // Reassignment moves every season on the request onto the new owner's
+ // quota, so it is charged in full rather than as a delta
+ const ownerChanging = requestUser.id !== previousOwnerId;
+
+ return requestLock.dispatch(userKey(requestUser.id), async () => {
+ if (req.body.mediaType === MediaType.MOVIE) {
+ if (ownerChanging && !request.ignoreQuota) {
+ const quotas = await requestUser.getQuota();
+
+ if (quotas.movie.restricted) {
+ return next({
+ status: 403,
+ message: 'Movie Quota exceeded.',
+ });
+ }
+ }
- return res.status(200).json(request);
+ request.serverId = req.body.serverId;
+ request.profileId = req.body.profileId;
+ request.rootFolder = req.body.rootFolder;
+ request.tags = req.body.tags;
+ request.requestedBy = requestUser as User;
+
+ await requestRepository.save(request);
+ } else if (req.body.mediaType === MediaType.TV) {
+ const mediaRepository = getRepository(Media);
+ request.serverId = req.body.serverId;
+ request.profileId = req.body.profileId;
+ request.rootFolder = req.body.rootFolder;
+ request.languageProfileId = req.body.languageProfileId;
+ request.tags = req.body.tags;
+ request.requestedBy = requestUser as User;
+
+ const requestedSeasons = req.body.seasons as number[] | undefined;
+
+ if (!requestedSeasons || requestedSeasons.length === 0) {
+ throw new Error(
+ 'Missing seasons. If you want to cancel a series request, use the DELETE method.'
+ );
+ }
+
+ // Same key as create, so an edit cannot claim a season that a new
+ // request is taking at the same moment
+ return mediaLock.dispatch(
+ mediaKey(MediaType.TV, request.media.tmdbId),
+ async () => {
+ // Get existing media so we can work with all the requests
+ const media = await mediaRepository.findOneOrFail({
+ where: {
+ tmdbId: request.media.tmdbId,
+ mediaType: MediaType.TV,
+ },
+ relations: { requests: true },
+ });
+
+ // Get all requested seasons that are not part of this request we are editing
+ const existingSeasons = media.requests
+ .filter(
+ (r) =>
+ r.is4k === request.is4k &&
+ r.id !== request.id &&
+ r.status !== MediaRequestStatus.DECLINED &&
+ r.status !== MediaRequestStatus.COMPLETED
+ )
+ .reduce((seasons, r) => {
+ const combinedSeasons = r.seasons.map(
+ (season) => season.seasonNumber
+ );
+
+ return [...seasons, ...combinedSeasons];
+ }, [] as number[]);
+
+ const currentSeasons = request.seasons.map(
+ (s) => s.seasonNumber
+ );
+
+ // Seasons the media already covers cannot be requested again, while
+ // the ones this request holds stay on it
+ const coveredSeasons = (media.seasons ?? [])
+ .filter(
+ (season) =>
+ season[request.is4k ? 'status4k' : 'status'] !==
+ MediaStatus.UNKNOWN &&
+ season[request.is4k ? 'status4k' : 'status'] !==
+ MediaStatus.DELETED
+ )
+ .map((season) => season.seasonNumber)
+ .filter((sn) => !currentSeasons.includes(sn));
+
+ const filteredSeasons = requestedSeasons.filter(
+ (rs) => !existingSeasons.includes(rs)
+ );
+
+ const keptSeasons = filteredSeasons.filter((sn) =>
+ currentSeasons.includes(sn)
+ );
+
+ const newSeasons = filteredSeasons.filter(
+ (sn) =>
+ !currentSeasons.includes(sn) && !coveredSeasons.includes(sn)
+ );
+
+ const resultingSeasonCount =
+ keptSeasons.length + newSeasons.length;
+
+ if (resultingSeasonCount === 0) {
+ return next({
+ status: 202,
+ message: 'No seasons available to request',
+ });
+ }
+
+ if (!request.ignoreQuota) {
+ const quotas = await requestUser.getQuota();
+
+ // Only the seasons getQuota already counted for this owner are
+ // paid for, so the edit is charged for everything it left out
+ const quotaWindowStart = new Date();
+ if (quotas.tv.days) {
+ quotaWindowStart.setDate(
+ quotaWindowStart.getDate() - quotas.tv.days
+ );
+ }
+
+ const countedAlready =
+ !ownerChanging &&
+ (!quotas.tv.days || request.createdAt > quotaWindowStart);
+
+ const priorSeasonCount = countedAlready
+ ? request.seasons.length
+ : 0;
+ const requiredSeasons =
+ resultingSeasonCount - priorSeasonCount;
+
+ if (
+ quotas.tv.limit &&
+ requiredSeasons > (quotas.tv.remaining ?? 0)
+ ) {
+ return next({
+ status: 403,
+ message: 'Series Quota exceeded.',
+ });
+ }
+ }
+
+ request.seasons = request.seasons.filter((rs) =>
+ keptSeasons.includes(rs.seasonNumber)
+ );
+
+ if (newSeasons.length > 0) {
+ logger.debug('Adding new seasons to request', {
+ label: 'Media Request',
+ newSeasons,
+ });
+ request.seasons.push(
+ ...newSeasons.map(
+ (ns) =>
+ new SeasonRequest({
+ seasonNumber: ns,
+ status: MediaRequestStatus.PENDING,
+ })
+ )
+ );
+ }
+
+ await requestRepository.save(request);
+
+ return res.status(200).json(request);
+ }
+ );
+ }
+
+ return res.status(200).json(request);
+ });
+ });
} catch (e) {
next({ status: 500, message: e.message });
}
@@ -600,27 +760,30 @@ requestRoutes.put<{ requestId: string }>(
requestRoutes.delete('/:requestId', async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
+ const requestId = Number(req.params.requestId);
try {
- const request = await requestRepository.findOneOrFail({
- where: { id: Number(req.params.requestId) },
- relations: { requestedBy: true, modifiedBy: true },
- });
-
- if (
- !req.user?.hasPermission(Permission.MANAGE_REQUESTS) &&
- (request.requestedBy.id !== req.user?.id ||
- request.status !== MediaRequestStatus.PENDING)
- ) {
- return next({
- status: 401,
- message: 'You do not have permission to delete this request.',
+ return await requestLock.dispatch(requestKey(requestId), async () => {
+ const request = await requestRepository.findOneOrFail({
+ where: { id: requestId },
+ relations: { requestedBy: true, modifiedBy: true },
});
- }
- await requestRepository.remove(request);
+ if (
+ !req.user?.hasPermission(Permission.MANAGE_REQUESTS) &&
+ (request.requestedBy.id !== req.user?.id ||
+ request.status !== MediaRequestStatus.PENDING)
+ ) {
+ return next({
+ status: 401,
+ message: 'You do not have permission to delete this request.',
+ });
+ }
+
+ await requestRepository.remove(request);
- return res.status(204).send();
+ return res.status(204).send();
+ });
} catch (e) {
logger.error('Something went wrong deleting a request.', {
label: 'API',
@@ -637,19 +800,29 @@ requestRoutes.post<{
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
+ const requestId = Number(req.params.requestId);
try {
- const request = await requestRepository.findOneOrFail({
- where: { id: Number(req.params.requestId) },
- relations: { requestedBy: true, modifiedBy: true },
- });
+ return await requestLock.dispatch(requestKey(requestId), async () => {
+ const request = await requestRepository.findOneOrFail({
+ where: { id: requestId },
+ relations: { requestedBy: true, modifiedBy: true },
+ });
- // this also triggers updating the parent media's status & sending to *arr
- request.status = MediaRequestStatus.APPROVED;
- request.modifiedBy = req.user;
- await requestRepository.save(request);
+ if (request.status !== MediaRequestStatus.FAILED) {
+ return next({
+ status: 409,
+ message: 'Only failed requests can be retried.',
+ });
+ }
- return res.status(200).json(request);
+ // this also triggers updating the parent media's status & sending to *arr
+ request.status = MediaRequestStatus.APPROVED;
+ request.modifiedBy = req.user;
+ await requestRepository.save(request);
+
+ return res.status(200).json(request);
+ });
} catch (e) {
logger.error('Error processing request retry', {
label: 'Media Request',
@@ -662,38 +835,50 @@ requestRoutes.post<{
requestRoutes.post<{
requestId: string;
- status: 'pending' | 'approve' | 'decline';
+ status: 'approve' | 'decline';
}>(
'/:requestId/:status',
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
+ const requestId = Number(req.params.requestId);
try {
- const request = await requestRepository.findOneOrFail({
- where: { id: Number(req.params.requestId) },
- relations: { requestedBy: true, modifiedBy: true },
- });
+ return await requestLock.dispatch(requestKey(requestId), async () => {
+ const request = await requestRepository.findOneOrFail({
+ where: { id: requestId },
+ relations: { requestedBy: true, modifiedBy: true },
+ });
- let newStatus: MediaRequestStatus;
+ let newStatus: MediaRequestStatus;
+
+ switch (req.params.status) {
+ case 'approve':
+ newStatus = MediaRequestStatus.APPROVED;
+ break;
+ case 'decline':
+ newStatus = MediaRequestStatus.DECLINED;
+ break;
+ default:
+ return next({
+ status: 400,
+ message: 'Status must be approve or decline.',
+ });
+ }
- switch (req.params.status) {
- case 'pending':
- newStatus = MediaRequestStatus.PENDING;
- break;
- case 'approve':
- newStatus = MediaRequestStatus.APPROVED;
- break;
- case 'decline':
- newStatus = MediaRequestStatus.DECLINED;
- break;
- }
+ if (request.status !== MediaRequestStatus.PENDING) {
+ return next({
+ status: 409,
+ message: 'Only pending requests can be approved or declined.',
+ });
+ }
- request.status = newStatus;
- request.modifiedBy = req.user;
- await requestRepository.save(request);
+ request.status = newStatus;
+ request.modifiedBy = req.user;
+ await requestRepository.save(request);
- return res.status(200).json(request);
+ return res.status(200).json(request);
+ });
} catch (e) {
logger.error('Error processing request update', {
label: 'Media Request',
diff --git a/server/routes/search.ts b/server/routes/search.ts
index cccb38f286..03f7d8cf4b 100644
--- a/server/routes/search.ts
+++ b/server/routes/search.ts
@@ -1,7 +1,10 @@
+import Hardcover from '@server/api/hardcover';
+import type { HardcoverSearchMultiResponse } from '@server/api/hardcover/interfaces';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search';
+import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapSearchResults } from '@server/models/Search';
import { Router } from 'express';
@@ -11,7 +14,7 @@ const searchRoutes = Router();
searchRoutes.get('/', async (req, res, next) => {
const queryString = req.query.query as string;
const searchProvider = findSearchProvider(queryString.toLowerCase());
- let results: TmdbSearchMultiResponse;
+ let results: TmdbSearchMultiResponse | HardcoverSearchMultiResponse;
try {
if (searchProvider) {
@@ -23,6 +26,20 @@ searchRoutes.get('/', async (req, res, next) => {
language: (req.query.language as string) ?? req.locale,
query: queryString,
});
+ } else if (req.query.type === 'hardcover') {
+ if (!getSettings().main.hardcoverapikey) {
+ return next({
+ status: 503,
+ message: 'Hardcover API key is not configured.',
+ });
+ }
+
+ const hardcover = new Hardcover();
+
+ results = await hardcover.search({
+ query: queryString,
+ page: Number(req.query.page),
+ });
} else {
const tmdb = new TheMovieDb();
diff --git a/server/routes/series.ts b/server/routes/series.ts
new file mode 100644
index 0000000000..5da6f569c3
--- /dev/null
+++ b/server/routes/series.ts
@@ -0,0 +1,46 @@
+import Hardcover from '@server/api/hardcover';
+import { MediaType } from '@server/constants/media';
+import Media from '@server/entity/Media';
+import { getSettings } from '@server/lib/settings';
+import logger from '@server/logger';
+import { mapSeries } from '@server/models/Series';
+import { Router } from 'express';
+
+const seriesRoutes = Router();
+
+seriesRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
+ if (!getSettings().main.hardcoverapikey) {
+ return next({
+ status: 503,
+ message: 'Hardcover API key is not configured.',
+ });
+ }
+
+ const hardcover = new Hardcover();
+
+ try {
+ const series = await hardcover.getSeries(Number(req.params.id));
+
+ const media = await Media.getRelatedMedia(
+ req.user,
+ series.book_series.map((book) => ({
+ tmdbId: book.book.id,
+ mediaType: MediaType.BOOK,
+ }))
+ );
+
+ return res.status(200).json(mapSeries(series, media));
+ } catch (e) {
+ logger.debug('Something went wrong retrieving series', {
+ label: 'API',
+ errorMessage: e.message,
+ seriesId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series.',
+ });
+ }
+});
+
+export default seriesRoutes;
diff --git a/server/routes/service.ts b/server/routes/service.ts
index 8f6c92b0de..b24058b8f7 100644
--- a/server/routes/service.ts
+++ b/server/routes/service.ts
@@ -1,4 +1,5 @@
import RadarrAPI from '@server/api/servarr/radarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
@@ -213,4 +214,76 @@ serviceRoutes.get<{ tmdbId: string }>(
}
);
+serviceRoutes.get('/readarr', async (req, res) => {
+ const settings = getSettings();
+
+ const filteredReadarrServers: ServiceCommonServer[] = settings.readarr.map(
+ (readarr) => ({
+ id: readarr.id,
+ name: readarr.name,
+ is4k: readarr.is4k,
+ isDefault: readarr.isDefault,
+ activeDirectory: readarr.activeDirectory,
+ activeProfileId: readarr.activeProfileId,
+ activeMetadataProfileId: readarr.activeMetadataProfileId,
+ activeTags: readarr.tags ?? [],
+ })
+ );
+
+ return res.status(200).json(filteredReadarrServers);
+});
+
+serviceRoutes.get<{ readarrId: string }>(
+ '/readarr/:readarrId',
+ async (req, res, next) => {
+ const settings = getSettings();
+
+ const readarrSettings = settings.readarr.find(
+ (readarr) => readarr.id === Number(req.params.readarrId)
+ );
+
+ if (!readarrSettings) {
+ return next({
+ status: 404,
+ message: 'Readarr server with provided ID does not exist.',
+ });
+ }
+
+ const readarr = new ReadarrAPI({
+ apiKey: readarrSettings.apiKey,
+ url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'),
+ });
+
+ const profiles = await readarr.getProfiles();
+ const rootFolders = await readarr.getRootFolders();
+ const tags = await readarr.getTags();
+ const metadataProfiles = await readarr.getMetadataProfiles();
+
+ return res.status(200).json({
+ server: {
+ id: readarrSettings.id,
+ name: readarrSettings.name,
+ is4k: readarrSettings.is4k,
+ isDefault: readarrSettings.isDefault,
+ activeDirectory: readarrSettings.activeDirectory,
+ activeProfileId: readarrSettings.activeProfileId,
+ activeMetadataProfileId: readarrSettings.activeMetadataProfileId,
+ activeTags: readarrSettings.tags,
+ },
+ profiles: profiles.map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ })),
+ rootFolders: rootFolders.map((folder) => ({
+ id: folder.id,
+ freeSpace: folder.freeSpace,
+ path: folder.path,
+ totalSpace: folder.totalSpace,
+ })),
+ metadataProfiles,
+ tags,
+ } as ServiceCommonServerWithDetails);
+ }
+);
+
export default serviceRoutes;
diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts
index 12b5746595..3f2b77dd1e 100644
--- a/server/routes/settings/index.ts
+++ b/server/routes/settings/index.ts
@@ -1,3 +1,4 @@
+import type { JellyfinLibrary } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
@@ -39,9 +40,11 @@ import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
import { URL } from 'url';
+import { z } from 'zod';
import metadataRoutes from './metadata';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
+import readarrRoutes from './readarr';
import sonarrRoutes from './sonarr';
const settingsRoutes = Router();
@@ -49,9 +52,14 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
+settingsRoutes.use('/readarr', readarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/metadatas', metadataRoutes);
+const libraryUpdateSchema = z.object({
+ enabled: z.boolean(),
+});
+
const filteredMainSettings = (
user: User,
main: MainSettings
@@ -76,6 +84,20 @@ settingsRoutes.get('/main', (req, res, next) => {
settingsRoutes.post('/main', async (req, res) => {
const settings = getSettings();
+ if (req.body.hardcoverapikey) {
+ req.body.hardcoverapikey = req.body.hardcoverapikey
+ .trim()
+ .replace(/^Bearer\s+/i, '');
+ }
+
+ if (
+ req.body.hardcoverapikey &&
+ req.body.hardcoverapikey !== settings.main.hardcoverapikey
+ ) {
+ const hardcoverCache = cacheManager.getCache('hardcover');
+ hardcoverCache.flush();
+ }
+
settings.main = merge(settings.main, req.body);
await settings.save();
@@ -229,28 +251,54 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
}
});
-settingsRoutes.get('/plex/library', async (req, res) => {
+settingsRoutes.get('/plex/library', (_req, res) => {
const settings = getSettings();
- if (req.query.sync) {
- const userRepository = getRepository(User);
- const admin = await userRepository.findOneOrFail({
- select: { id: true, plexToken: true },
- where: { id: 1 },
- });
- const plexapi = new PlexAPI({ plexToken: admin.plexToken });
+ return res.status(200).json(settings.plex.libraries);
+});
- await plexapi.syncLibraries();
+settingsRoutes.put('/plex/library/:libraryId', async (req, res, next) => {
+ const settings = getSettings();
+
+ const bodyResult = libraryUpdateSchema.safeParse(req.body);
+
+ if (!bodyResult.success) {
+ return next({ status: 400, message: 'Invalid request body.' });
}
- const enabledLibraries = req.query.enable
- ? (req.query.enable as string).split(',')
- : [];
- settings.plex.libraries = settings.plex.libraries.map((library) => ({
- ...library,
- enabled: enabledLibraries.includes(library.id),
- }));
+ const library = settings.plex.libraries.find(
+ (l) => l.id === req.params.libraryId
+ );
+
+ if (!library) {
+ return next({ status: 404, message: 'Library does not exist.' });
+ }
+
+ library.enabled = bodyResult.data.enabled;
await settings.save();
+
+ return res.status(200).json(library);
+});
+
+settingsRoutes.post('/plex/library/sync', async (_req, res, next) => {
+ const settings = getSettings();
+
+ const userRepository = getRepository(User);
+ const admin = await userRepository.findOneOrFail({
+ select: { id: true, plexToken: true },
+ where: { id: 1 },
+ });
+ const plexapi = new PlexAPI({ plexToken: admin.plexToken });
+
+ try {
+ await plexapi.syncLibraries();
+ } catch (e) {
+ return next({
+ status: e.statusCode ?? 500,
+ message: e.errorCode ?? ApiErrorCode.Unknown,
+ });
+ }
+
return res.status(200).json(settings.plex.libraries);
});
@@ -330,25 +378,56 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
return res.status(200).json(settings.jellyfin);
});
-settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
+settingsRoutes.get('/jellyfin/library', (_req, res) => {
const settings = getSettings();
- if (req.query.sync) {
- const userRepository = getRepository(User);
- const admin = await userRepository.findOneOrFail({
- select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
- where: { id: 1 },
- order: { id: 'ASC' },
- });
- const jellyfinClient = new JellyfinAPI(
- getHostname(),
- settings.jellyfin.apiKey,
- admin.jellyfinDeviceId ?? ''
- );
+ return res.status(200).json(settings.jellyfin.libraries);
+});
+
+settingsRoutes.put('/jellyfin/library/:libraryId', async (req, res, next) => {
+ const settings = getSettings();
+
+ const bodyResult = libraryUpdateSchema.safeParse(req.body);
+
+ if (!bodyResult.success) {
+ return next({ status: 400, message: 'Invalid request body.' });
+ }
+
+ const library = settings.jellyfin.libraries.find(
+ (l) => l.id === req.params.libraryId
+ );
+
+ if (!library) {
+ return next({ status: 404, message: 'Library does not exist.' });
+ }
+
+ library.enabled = bodyResult.data.enabled;
+ await settings.save();
+
+ return res.status(200).json(library);
+});
+
+settingsRoutes.post('/jellyfin/library/sync', async (_req, res, next) => {
+ const settings = getSettings();
+
+ const userRepository = getRepository(User);
+ const admin = await userRepository.findOneOrFail({
+ select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
+ where: { id: 1 },
+ order: { id: 'ASC' },
+ });
+ const jellyfinClient = new JellyfinAPI(
+ getHostname(),
+ settings.jellyfin.apiKey,
+ admin.jellyfinDeviceId ?? ''
+ );
+
+ jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
- jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
+ let libraries: JellyfinLibrary[];
- const libraries = await jellyfinClient.getLibraries();
+ try {
+ libraries = await jellyfinClient.getLibraries();
if (libraries.length === 0) {
// Check if no libraries are found due to the fallback to user views
@@ -365,31 +444,30 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
}
-
- const newLibraries: Library[] = libraries.map((library) => {
- const existing = settings.jellyfin.libraries.find(
- (l) => l.id === library.key && l.name === library.title
- );
-
- return {
- id: library.key,
- name: library.title,
- enabled: existing?.enabled ?? false,
- type: library.type,
- };
+ } catch (e) {
+ return next({
+ status: e.statusCode ?? 500,
+ message: e.errorCode ?? ApiErrorCode.Unknown,
});
-
- settings.jellyfin.libraries = newLibraries;
}
- const enabledLibraries = req.query.enable
- ? (req.query.enable as string).split(',')
- : [];
- settings.jellyfin.libraries = settings.jellyfin.libraries.map((library) => ({
- ...library,
- enabled: enabledLibraries.includes(library.id),
- }));
+ const newLibraries: Library[] = libraries.map((library) => {
+ const existing = settings.jellyfin.libraries.find(
+ (l) => l.id === library.key
+ );
+
+ return {
+ id: library.key,
+ name: library.title,
+ enabled: existing?.enabled ?? false,
+ type: library.type,
+ lastScan: existing?.lastScan,
+ };
+ });
+
+ settings.jellyfin.libraries = newLibraries;
await settings.save();
+
return res.status(200).json(settings.jellyfin.libraries);
});
@@ -761,6 +839,7 @@ settingsRoutes.get('/cache', async (_req, res) => {
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
+ const hardcoverImageCache = await ImageProxy.getImageStats('hardcover');
const stats: DnsStats | undefined = dnsCache?.getStats();
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
@@ -770,6 +849,7 @@ settingsRoutes.get('/cache', async (_req, res) => {
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
+ hardcover: hardcoverImageCache,
},
dnsCache: {
stats,
diff --git a/server/routes/settings/readarr.ts b/server/routes/settings/readarr.ts
new file mode 100644
index 0000000000..94cad09a6d
--- /dev/null
+++ b/server/routes/settings/readarr.ts
@@ -0,0 +1,185 @@
+import ReadarrAPI from '@server/api/servarr/readarr';
+import type { ReadarrSettings } from '@server/lib/settings';
+import { getSettings } from '@server/lib/settings';
+import logger from '@server/logger';
+import { Router } from 'express';
+
+const readarrRoutes = Router();
+
+readarrRoutes.get('/', (_req, res) => {
+ const settings = getSettings();
+
+ res.status(200).json(settings.readarr);
+});
+
+readarrRoutes.post('/', async (req, res) => {
+ const settings = getSettings();
+
+ const newReadarr = req.body as ReadarrSettings;
+ const lastItem = settings.readarr[settings.readarr.length - 1];
+ newReadarr.id = lastItem ? lastItem.id + 1 : 0;
+
+ // If we are setting this as the default, clear any previous defaults for the same type first
+ // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
+ // and are the default
+ if (req.body.isDefault) {
+ settings.readarr
+ .filter((readarrInstance) => readarrInstance.is4k === req.body.is4k)
+ .forEach((readarrInstance) => {
+ readarrInstance.isDefault = false;
+ });
+ }
+
+ settings.readarr = [...settings.readarr, newReadarr];
+ await settings.save();
+
+ return res.status(201).json(newReadarr);
+});
+
+readarrRoutes.post<
+ undefined,
+ Record,
+ ReadarrSettings & { tagLabel?: string }
+>('/test', async (req, res, next) => {
+ try {
+ const readarr = new ReadarrAPI({
+ apiKey: req.body.apiKey,
+ url: ReadarrAPI.buildUrl(req.body, '/api/v1'),
+ });
+
+ const urlBase = await readarr
+ .getSystemStatus()
+ .then((value) => value.urlBase)
+ .catch(() => req.body.baseUrl);
+ const profiles = await readarr.getProfiles();
+ const folders = await readarr.getRootFolders();
+ const metadataProfiles = await readarr.getMetadataProfiles();
+ const tags = await readarr.getTags();
+
+ return res.status(200).json({
+ profiles,
+ rootFolders: folders.map((folder) => ({
+ id: folder.id,
+ path: folder.path,
+ })),
+ metadataProfiles,
+ tags,
+ urlBase,
+ });
+ } catch (e) {
+ logger.error('Failed to test Readarr', {
+ label: 'Readarr',
+ message: e.message,
+ });
+
+ next({ status: 500, message: 'Failed to connect to Readarr' });
+ }
+});
+
+readarrRoutes.put<{ id: string }, ReadarrSettings, ReadarrSettings>(
+ '/:id',
+ async (req, res, next) => {
+ const settings = getSettings();
+
+ const readarrIndex = settings.readarr.findIndex(
+ (r) => r.id === Number(req.params.id)
+ );
+
+ if (readarrIndex === -1) {
+ return next({ status: '404', message: 'Settings instance not found' });
+ }
+
+ // If we are setting this as the default, clear any previous defaults for the same type first
+ // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
+ // and are the default
+ if (req.body.isDefault) {
+ settings.readarr
+ .filter((readarrInstance) => readarrInstance.is4k === req.body.is4k)
+ .forEach((readarrInstance) => {
+ readarrInstance.isDefault = false;
+ });
+ }
+
+ settings.readarr[readarrIndex] = {
+ ...req.body,
+ id: Number(req.params.id),
+ } as ReadarrSettings;
+ await settings.save();
+
+ return res.status(200).json(settings.readarr[readarrIndex]);
+ }
+);
+
+readarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
+ const settings = getSettings();
+
+ const readarrSettings = settings.readarr.find(
+ (r) => r.id === Number(req.params.id)
+ );
+
+ if (!readarrSettings) {
+ return next({ status: '404', message: 'Settings instance not found' });
+ }
+
+ const readarr = new ReadarrAPI({
+ apiKey: readarrSettings.apiKey,
+ url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'),
+ });
+
+ const profiles = await readarr.getProfiles();
+
+ return res.status(200).json(
+ profiles.map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ }))
+ );
+});
+
+readarrRoutes.get<{ id: string }>(
+ '/:id/metadataprofiles',
+ async (req, res, next) => {
+ const settings = getSettings();
+
+ const readarrSettings = settings.readarr.find(
+ (r) => r.id === Number(req.params.id)
+ );
+
+ if (!readarrSettings) {
+ return next({ status: '404', message: 'Settings instance not found' });
+ }
+
+ const readarr = new ReadarrAPI({
+ apiKey: readarrSettings.apiKey,
+ url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'),
+ });
+
+ const metadataProfiles = await readarr.getMetadataProfiles();
+
+ return res.status(200).json(
+ metadataProfiles.map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ }))
+ );
+ }
+);
+
+readarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
+ const settings = getSettings();
+
+ const readarrIndex = settings.readarr.findIndex(
+ (r) => r.id === Number(req.params.id)
+ );
+
+ if (readarrIndex === -1) {
+ return next({ status: '404', message: 'Settings instance not found' });
+ }
+
+ const removed = settings.readarr.splice(readarrIndex, 1);
+ settings.save();
+
+ return res.status(200).json(removed[0]);
+});
+
+export default readarrRoutes;
diff --git a/server/routes/tv.ts b/server/routes/tv.ts
index 743f407e48..865564ba66 100644
--- a/server/routes/tv.ts
+++ b/server/routes/tv.ts
@@ -42,7 +42,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
},
});
- const data = mapTvDetails(tv, media, onUserWatchlist);
+ const data = mapTvDetails(tv, media?.filter(req.user), onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
@@ -114,7 +114,8 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
results.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
@@ -158,7 +159,8 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
results.results.map((result) => ({
tmdbId: result.id,
mediaType: MediaType.TV,
- }))
+ })),
+ { includeActiveRequest: true }
);
return res.status(200).json({
diff --git a/server/routes/user/index.test.ts b/server/routes/user/index.test.ts
new file mode 100644
index 0000000000..2513ea7c78
--- /dev/null
+++ b/server/routes/user/index.test.ts
@@ -0,0 +1,147 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+
+import { MediaStatus, MediaType } from '@server/constants/media';
+import { getRepository } from '@server/datasource';
+import Media from '@server/entity/Media';
+import { User } from '@server/entity/User';
+import { Watchlist } from '@server/entity/Watchlist';
+import { getSettings } from '@server/lib/settings';
+import { checkUser, isAuthenticated } from '@server/middleware/auth';
+import authRoutes from '@server/routes/auth';
+import { setupTestDb } from '@server/test/db';
+import {
+ assertNoCredentials,
+ seedUserSettings,
+} from '@server/test/userSettings';
+import type { Express } from 'express';
+import express from 'express';
+import session from 'express-session';
+import request from 'supertest';
+import userRoutes from '.';
+
+let app: Express;
+
+function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(
+ session({
+ secret: 'test-secret',
+ resave: false,
+ saveUninitialized: false,
+ })
+ );
+ app.use(checkUser);
+ app.use('/auth', authRoutes);
+ app.use('/user', isAuthenticated(), userRoutes);
+ app.use(
+ (
+ err: { status?: number; message?: string },
+ _req: express.Request,
+ res: express.Response,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _next: express.NextFunction
+ ) => {
+ res
+ .status(err.status ?? 500)
+ .json({ status: err.status ?? 500, message: err.message });
+ }
+ );
+ return app;
+}
+
+before(async () => {
+ app = createApp();
+});
+
+setupTestDb();
+
+async function loginAs(email: string, password: string) {
+ const settings = getSettings();
+ const priorLocalLogin = settings.main.localLogin;
+ settings.main.localLogin = true;
+
+ try {
+ const agent = request.agent(app);
+ const res = await agent.post('/auth/local').send({ email, password });
+ assert.strictEqual(res.status, 200);
+ return agent;
+ } finally {
+ settings.main.localLogin = priorLocalLogin;
+ }
+}
+
+describe('GET /user/:id/watchlist', () => {
+ it('omits notification settings from every requestedBy in the page', async () => {
+ const owner = await seedUserSettings('demo@seerr.dev');
+
+ const media = await getRepository(Media).save(
+ new Media({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 12345,
+ status: MediaStatus.UNKNOWN,
+ status4k: MediaStatus.UNKNOWN,
+ })
+ );
+
+ await getRepository(Watchlist).save(
+ new Watchlist({
+ mediaType: MediaType.MOVIE,
+ tmdbId: 12345,
+ title: 'Watchlisted Movie',
+ ratingKey: 'rk-12345',
+ requestedBy: owner,
+ media,
+ })
+ );
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.get(`/user/${owner.id}/watchlist`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.results.length, 1);
+ assert.ok(!('settings' in res.body.results[0].requestedBy));
+ assertNoCredentials(res.body);
+ });
+});
+
+describe('GET /user/:id', () => {
+ it('still returns full settings to the user themselves', async () => {
+ const owner = await seedUserSettings('demo@seerr.dev');
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.get(`/user/${owner.id}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.settings.pgpKey, 'test-pgp-key');
+ assert.strictEqual(
+ res.body.settings.pushoverUserKey,
+ 'test-pushover-user-key'
+ );
+ });
+
+ it('still returns full settings to a manage-users admin', async () => {
+ const owner = await seedUserSettings('demo@seerr.dev');
+
+ const admin = await loginAs('admin@seerr.dev', 'test1234');
+ const res = await admin.get(`/user/${owner.id}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.settings.pgpKey, 'test-pgp-key');
+ });
+
+ it('strips settings for an unrelated user', async () => {
+ const admin = await getRepository(User).findOneOrFail({
+ where: { email: 'admin@seerr.dev' },
+ });
+ await seedUserSettings('admin@seerr.dev');
+
+ const agent = await loginAs('demo@seerr.dev', 'test1234');
+ const res = await agent.get(`/user/${admin.id}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.ok(!('settings' in res.body));
+ assertNoCredentials(res.body);
+ });
+});
diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts
index 663945027a..7d6234d9bb 100644
--- a/server/routes/user/index.ts
+++ b/server/routes/user/index.ts
@@ -244,13 +244,13 @@ router.post<
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);
- // Check for existing subscription by auth or endpoint within transaction
+ // Check for existing subscription by endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true },
- where: [
- { auth: req.body.auth, user: { id: req.user?.id } },
- { endpoint: req.body.endpoint, user: { id: req.user?.id } },
- ],
+ where: {
+ endpoint: req.body.endpoint,
+ user: { id: req.user?.id },
+ },
});
if (existingSubscription) {
@@ -279,17 +279,15 @@ router.post<
return;
}
- // Clean up old subscriptions from the same device (userAgent) for this user
- // iOS can silently refresh endpoints, leaving stale subscriptions in the database
- // Only clean up if we're creating a new subscription (not updating an existing one)
- if (req.body.userAgent) {
+ // Clean up only true endpoint rotations. Same push-service subscription
+ // (matched by `auth`) but with a stale endpoint. Matching on `auth`
+ // avoids deleting sibling devices that only share a user agent.
+ if (req.body.auth) {
const staleSubscriptions = await transactionalRepo.find({
relations: { user: true },
where: {
- userAgent: req.body.userAgent,
user: { id: req.user?.id },
- // Only remove subscriptions with different endpoints (stale ones)
- // Keep subscriptions that might be from different browsers/tabs
+ auth: req.body.auth,
endpoint: Not(req.body.endpoint),
},
});
@@ -672,6 +670,7 @@ router.post(
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
+ let refreshedUsers = 0;
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
@@ -696,6 +695,7 @@ router.post(
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
+ refreshedUsers += 1;
} else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
@@ -714,7 +714,10 @@ router.post(
}
}
- return res.status(201).json(User.filterMany(createdUsers));
+ return res.status(201).json({
+ createdUsers: User.filterMany(createdUsers),
+ refreshedUsers,
+ });
} catch (e) {
next({ status: 500, message: e.message });
}
diff --git a/server/routes/user/usersettings.test.ts b/server/routes/user/usersettings.test.ts
new file mode 100644
index 0000000000..90d0f7fbbc
--- /dev/null
+++ b/server/routes/user/usersettings.test.ts
@@ -0,0 +1,134 @@
+import assert from 'node:assert/strict';
+import { before, beforeEach, describe, it, mock } from 'node:test';
+
+import JellyfinAPI from '@server/api/jellyfin';
+import { MediaServerType } from '@server/constants/server';
+import { UserType } from '@server/constants/user';
+import { getRepository } from '@server/datasource';
+import { User } from '@server/entity/User';
+import { getSettings } from '@server/lib/settings';
+import { checkUser, isAuthenticated } from '@server/middleware/auth';
+import authRoutes from '@server/routes/auth';
+import { setupTestDb } from '@server/test/db';
+import type { Express } from 'express';
+import express from 'express';
+import session from 'express-session';
+import request from 'supertest';
+import userRoutes from '.';
+
+const defaultAuthenticateResponse = {
+ User: {
+ Id: 'jf-link-user-001',
+ Name: 'linkeduser',
+ ServerId: 'server-1',
+ Policy: { IsAdministrator: false },
+ },
+ AccessToken: 'fake-qc-access-token',
+};
+
+const authenticateQCMock = mock.method(
+ JellyfinAPI.prototype,
+ 'authenticateQuickConnect',
+ async () => ({ ...defaultAuthenticateResponse })
+);
+
+let app: Express;
+
+function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(
+ session({
+ secret: 'test-secret',
+ resave: false,
+ saveUninitialized: false,
+ })
+ );
+ app.use(checkUser);
+ app.use('/auth', authRoutes);
+ app.use('/user', isAuthenticated(), userRoutes);
+ app.use(
+ (
+ err: { status?: number; message?: string },
+ _req: express.Request,
+ res: express.Response,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _next: express.NextFunction
+ ) => {
+ res
+ .status(err.status ?? 500)
+ .json({ status: err.status ?? 500, message: err.message });
+ }
+ );
+ return app;
+}
+
+before(async () => {
+ app = createApp();
+});
+
+setupTestDb();
+
+function configureJellyfin() {
+ const settings = getSettings();
+ settings.main.mediaServerType = MediaServerType.JELLYFIN;
+ settings.jellyfin.ip = 'localhost';
+ settings.jellyfin.port = 8096;
+ settings.jellyfin.useSsl = false;
+ settings.jellyfin.urlBase = '';
+}
+
+async function loginAs(email: string, password: string) {
+ const settings = getSettings();
+ settings.main.localLogin = true;
+
+ const agent = request.agent(app);
+ const res = await agent.post('/auth/local').send({ email, password });
+
+ assert.strictEqual(res.status, 200);
+ return { agent, userId: res.body.id as number };
+}
+
+describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => {
+ beforeEach(() => {
+ authenticateQCMock.mock.resetCalls();
+ authenticateQCMock.mock.mockImplementation(async () => ({
+ ...defaultAuthenticateResponse,
+ }));
+ configureJellyfin();
+ });
+
+ it('links the account when the media server is Jellyfin', async () => {
+ const { agent, userId } = await loginAs('demo@seerr.dev', 'test1234');
+
+ const res = await agent
+ .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`)
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 204);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 1);
+
+ const user = await getRepository(User).findOneOrFail({
+ where: { id: userId },
+ });
+ assert.strictEqual(user.jellyfinUserId, 'jf-link-user-001');
+ assert.strictEqual(user.userType, UserType.JELLYFIN);
+ });
+
+ it('returns 403 when the media server is Emby', async () => {
+ const { agent, userId } = await loginAs('demo@seerr.dev', 'test1234');
+ getSettings().main.mediaServerType = MediaServerType.EMBY;
+
+ const res = await agent
+ .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`)
+ .send({ secret: 'abc123def456abc123def456' });
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
+
+ const user = await getRepository(User).findOneOrFail({
+ where: { id: userId },
+ });
+ assert.strictEqual(user.jellyfinUserId, null);
+ });
+});
diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts
index 784f7b5fd8..4f54386920 100644
--- a/server/routes/user/usersettings.ts
+++ b/server/routes/user/usersettings.ts
@@ -14,6 +14,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
+import { quickConnectSecret } from '@server/routes/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import {
@@ -48,7 +49,6 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({
username: user.username,
email: user.email,
- discordId: user.settings?.discordId,
locale: user.settings?.locale,
discoverRegion: user.settings?.discoverRegion,
streamingRegion: user.settings?.streamingRegion,
@@ -57,6 +57,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
movieQuotaDays: user.movieQuotaDays,
tvQuotaLimit: user.tvQuotaLimit,
tvQuotaDays: user.tvQuotaDays,
+ bookQuotaDays: user.bookQuotaDays,
+ bookQuotaLimit: user.bookQuotaLimit,
globalMovieQuotaDays: defaultQuotas.movie.quotaDays,
globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
globalTvQuotaDays: defaultQuotas.tv.quotaDays,
@@ -117,12 +119,13 @@ userSettingsRoutes.post<
user.movieQuotaLimit = req.body.movieQuotaLimit;
user.tvQuotaDays = req.body.tvQuotaDays;
user.tvQuotaLimit = req.body.tvQuotaLimit;
+ user.bookQuotaDays = req.body.bookQuotaDays;
+ user.bookQuotaLimit = req.body.bookQuotaLimit;
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
- discordId: req.body.discordId,
locale: req.body.locale,
discoverRegion: req.body.discoverRegion,
streamingRegion: req.body.streamingRegion,
@@ -131,7 +134,6 @@ userSettingsRoutes.post<
watchlistSyncTv: req.body.watchlistSyncTv,
});
} else {
- user.settings.discordId = req.body.discordId;
user.settings.locale = req.body.locale;
user.settings.discoverRegion = req.body.discoverRegion;
user.settings.streamingRegion = req.body.streamingRegion;
@@ -144,7 +146,6 @@ userSettingsRoutes.post<
return res.status(200).json({
username: savedUser.username,
- discordId: savedUser.settings?.discordId,
locale: savedUser.settings?.locale,
discoverRegion: savedUser.settings?.discoverRegion,
streamingRegion: savedUser.settings?.streamingRegion,
@@ -518,6 +519,81 @@ userSettingsRoutes.delete<{ id: string }>(
}
);
+userSettingsRoutes.post<{ secret: string }>(
+ '/linked-accounts/jellyfin/quickconnect',
+ isOwnProfile(),
+ async (req, res) => {
+ const settings = getSettings();
+ const userRepository = getRepository(User);
+
+ if (!req.user) {
+ return res.status(401).json({ code: ApiErrorCode.Unauthorized });
+ }
+
+ const result = quickConnectSecret.safeParse(req.body);
+ if (!result.success) {
+ return res.status(400).json({ message: 'Invalid secret format' });
+ }
+
+ const { secret } = result.data;
+
+ if (
+ settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
+ settings.main.mediaServerType !== MediaServerType.EMBY
+ ) {
+ return res
+ .status(500)
+ .json({ message: 'Jellyfin/Emby login is disabled' });
+ }
+
+ if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
+ return res
+ .status(403)
+ .json({ message: 'Quick Connect is only supported by Jellyfin.' });
+ }
+
+ const hostname = getHostname();
+ const jellyfinServer = new JellyfinAPI(hostname);
+
+ try {
+ const account = await jellyfinServer.authenticateQuickConnect(secret);
+
+ if (
+ await userRepository.exist({
+ where: { jellyfinUserId: account.User.Id },
+ })
+ ) {
+ return res.status(422).json({
+ message: 'The specified account is already linked to a Seerr user',
+ });
+ }
+
+ const user = req.user;
+ const deviceId = Buffer.from(
+ user.id === 1 ? 'BOT_seerr' : `BOT_seerr_${user.username ?? ''}`
+ ).toString('base64');
+
+ user.userType = UserType.JELLYFIN;
+ user.jellyfinUserId = account.User.Id;
+ user.jellyfinUsername = account.User.Name;
+ user.jellyfinAuthToken = account.AccessToken;
+ user.jellyfinDeviceId = deviceId;
+ await userRepository.save(user);
+
+ return res.status(204).send();
+ } catch (e) {
+ logger.error('Failed to link account with Quick Connect.', {
+ label: 'API',
+ ip: req.ip,
+ error: e,
+ });
+
+ const status = e instanceof ApiError ? e.statusCode : 500;
+ return res.status(status).send();
+ }
+ }
+);
+
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),
@@ -543,7 +619,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
settings?.discord.enabled && settings.discord.options.enableMentions
? settings.discord.types
: 0,
- discordId: user.settings?.discordId,
+ discordIds: user.settings?.discordIds ?? [],
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
pushoverUserKey: user.settings?.pushoverUserKey,
@@ -585,11 +661,14 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
});
}
+ const discordIds =
+ req.body.discordIds?.filter((id: string) => id !== '') ?? [];
+
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
pgpKey: req.body.pgpKey,
- discordId: req.body.discordId,
+ discordIds,
pushbulletAccessToken: req.body.pushbulletAccessToken,
pushoverApplicationToken: req.body.pushoverApplicationToken,
pushoverUserKey: req.body.pushoverUserKey,
@@ -600,7 +679,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
});
} else {
user.settings.pgpKey = req.body.pgpKey;
- user.settings.discordId = req.body.discordId;
+ user.settings.discordIds = discordIds;
user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken;
user.settings.pushoverApplicationToken =
req.body.pushoverApplicationToken;
@@ -621,7 +700,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
return res.status(200).json({
pgpKey: user.settings.pgpKey,
- discordId: user.settings.discordId,
+ discordIds: user.settings.discordIds ?? [],
pushbulletAccessToken: user.settings.pushbulletAccessToken,
pushoverApplicationToken: user.settings.pushoverApplicationToken,
pushoverUserKey: user.settings.pushoverUserKey,
diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts
index ccde8bb74b..bd917c447d 100644
--- a/server/scripts/prepareTestDb.ts
+++ b/server/scripts/prepareTestDb.ts
@@ -12,6 +12,7 @@ const prepareDb = async () => {
await seedTestDb({
preserveDb: process.env.PRESERVE_DB === 'true',
withMigrations: process.env.WITH_MIGRATIONS === 'true',
+ allowOutsideTest: true,
});
};
diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts
index 3a7a37ebd9..cfa9681aa8 100644
--- a/server/subscriber/IssueCommentSubscriber.ts
+++ b/server/subscriber/IssueCommentSubscriber.ts
@@ -1,7 +1,7 @@
+import Hardcover from '@server/api/hardcover';
import TheMovieDb from '@server/api/themoviedb';
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
-import { getRepository } from '@server/datasource';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
@@ -9,7 +9,11 @@ import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { sortBy } from 'lodash';
-import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
+import type {
+ EntityManager,
+ EntitySubscriberInterface,
+ InsertEvent,
+} from 'typeorm';
import { EventSubscriber } from 'typeorm';
@EventSubscriber()
@@ -18,24 +22,28 @@ export class IssueCommentSubscriber implements EntitySubscriberInterface): void {
+ public async afterInsert(event: InsertEvent): Promise {
if (!event.entity) {
return;
}
- this.sendIssueCommentNotification(event.entity);
+ await this.sendIssueCommentNotification(event.manager, event.entity);
}
}
diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts
index e600cd14f5..d301984a2f 100644
--- a/server/subscriber/IssueSubscriber.ts
+++ b/server/subscriber/IssueSubscriber.ts
@@ -1,3 +1,4 @@
+import Hardcover from '@server/api/hardcover';
import TheMovieDb from '@server/api/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
@@ -23,6 +24,7 @@ export class IssueSubscriber implements EntitySubscriberInterface {
let title: string;
let image: string;
const tmdb = new TheMovieDb();
+ const hardcover = new Hardcover();
try {
if (entity.media.mediaType === MediaType.MOVIE) {
@@ -32,6 +34,17 @@ export class IssueSubscriber implements EntitySubscriberInterface {
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
+ } else if (entity.media.mediaType === MediaType.BOOK) {
+ const book = await hardcover.getBook(entity.media.tmdbId);
+
+ title = `${book.title}${
+ book.release_date ? ` (${book.release_date.slice(0, 4)})` : ''
+ }`;
+ image = book.image?.url
+ ? book.image.url
+ : `https://assets.hardcover.app/static/covers/cover${
+ (entity.media.tmdbId % 9) + 1
+ }.png`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts
index 0f10c33804..6e7a40c998 100644
--- a/server/subscriber/MediaRequestSubscriber.ts
+++ b/server/subscriber/MediaRequestSubscriber.ts
@@ -1,5 +1,8 @@
+import Hardcover from '@server/api/hardcover';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
+import type { ReadarrBookOptions } from '@server/api/servarr/readarr';
+import ReadarrAPI from '@server/api/servarr/readarr';
import type {
AddSeriesOptions,
SonarrSeries,
@@ -20,6 +23,7 @@ import SeasonRequest from '@server/entity/SeasonRequest';
import notificationManager, { Notification } from '@server/lib/notifications';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
+import { withNestedTransaction } from '@server/utils/nestedTransaction';
import { isEqual, truncate } from 'lodash';
import type {
EntityManager,
@@ -180,13 +184,16 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface {
+ public async sendToRadarr(
+ entity: MediaRequest,
+ manager: EntityManager
+ ): Promise {
if (
entity.status === MediaRequestStatus.APPROVED &&
entity.type === MediaType.MOVIE
) {
try {
- const mediaRepository = getRepository(Media);
+ const mediaRepository = manager.getRepository(Media);
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
@@ -280,13 +287,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface {
+ // Needs its own repository as this runs detached from the request transaction
+ const mediaRepository = getRepository(Media);
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: entity.media.id },
@@ -444,8 +453,8 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface {
+ public async sendToSonarr(
+ entity: MediaRequest,
+ manager: EntityManager
+ ): Promise {
if (
entity.status === MediaRequestStatus.APPROVED &&
entity.type === MediaType.TV
) {
try {
- const mediaRepository = getRepository(Media);
+ const mediaRepository = manager.getRepository(Media);
const settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
logger.warn(
@@ -549,7 +561,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface {
season.status = MediaRequestStatus.COMPLETED;
@@ -567,43 +579,44 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface keyword.id === ANIME_KEYWORD_ID
+ );
- // Change series type to anime if the anime keyword is present on tmdb
- if (
- series.keywords.results.some(
- (keyword) => keyword.id === ANIME_KEYWORD_ID
- )
- ) {
+ // seriesType only controls how Sonarr parses/numbers episodes
+ // it is sent in the addSeries payload and must not gate anime routing
+ let seriesType: SonarrSeries['seriesType'] =
+ sonarrSettings.seriesType ?? 'standard';
+
+ if (isAnime) {
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
}
let rootFolder =
- seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
+ isAnime && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory;
let qualityProfile =
- seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
+ isAnime && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId;
let languageProfile =
- seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
+ isAnime && sonarrSettings.activeAnimeLanguageProfileId
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
- let tags =
- seriesType === 'anime'
- ? sonarrSettings.animeTags
- ? [...sonarrSettings.animeTags]
- : []
- : sonarrSettings.tags
- ? [...sonarrSettings.tags]
- : [];
+ let tags = isAnime
+ ? sonarrSettings.animeTags
+ ? [...sonarrSettings.animeTags]
+ : []
+ : sonarrSettings.tags
+ ? [...sonarrSettings.tags]
+ : [];
if (
entity.rootFolder &&
@@ -719,6 +732,8 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface {
+ // Needs its own repository as this runs detached from the request transaction
+ const mediaRepository = getRepository(Media);
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: entity.media.id },
@@ -787,8 +802,8 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface {
- const mediaRepository = getRepository(Media);
+ public async sendToReadarr(
+ entity: MediaRequest,
+ manager: EntityManager
+ ): Promise