chore(release): merge develop into main

This commit is contained in:
fallenbagel
2026-04-16 03:39:50 +08:00
287 changed files with 15425 additions and 13287 deletions

View File

@@ -1,69 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
'plugin:jsx-a11y/recommended',
'plugin:@next/next/recommended',
'prettier',
],
parserOptions: {
ecmaVersion: 6,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'warn', // disable the rule for now to replicate previous behavior
'@typescript-eslint/camelcase': 0,
'@typescript-eslint/no-use-before-define': 0,
'jsx-a11y/no-noninteractive-tabindex': 0,
'arrow-parens': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'no-console': 1,
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'formatjs/no-offset': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/array-type': ['error', { default: 'array' }],
'jsx-a11y/no-onchange': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
},
],
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: true },
],
},
overrides: [
{
files: ['**/*.tsx'],
plugins: ['react'],
rules: {
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
},
},
],
plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'],
settings: {
react: {
pragma: 'React',
version: '16.8',
},
},
env: {
browser: true,
node: true,
jest: true,
es6: true,
},
reportUnusedDisableDirectives: true,
};

24
.github/cliff.toml vendored
View File

@@ -69,23 +69,19 @@ commit_preprocessors = [
{ pattern = '.*\[ci skip\].*', replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = '.*\(helm\).*', skip = true },
{ message = '^chore\(release\): prepare for', skip = true },
{ message = '^chore\(deps.*\)', skip = true },
{ body = ".*security", group = "<!-- 0 -->🛡️ Security" },
{ message = "^feat", group = "<!-- 1 -->🚀 Features" },
{ message = "^fix", group = "<!-- 2 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📖 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore\\(git-sync\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^refactor", group = "<!-- 5 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 6 -->🎨 Styling" },
{ message = "^test", group = "<!-- 7 -->🧪 Testing" },
{ message = "^chore|^ci", group = "<!-- 8 -->⚙️ Miscellaneous Tasks" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = '.*\[skip ci\].*', skip = true },
{ message = '.*\[ci skip\].*', skip = true },
]
protect_breaking_commits = false
tag_pattern = "v?[0-9]+\\.[0-9]+\\.[0-9]+.*"

View File

@@ -90,7 +90,7 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
container: node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
@@ -127,6 +127,51 @@ jobs:
- name: Build
run: pnpm build
unit-test:
name: Unit Tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe
permissions:
checks: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Pnpm Setup
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
env:
CI: true
run: pnpm install
- name: Run tests
env:
CI: true
run: pnpm test
- name: Publish test report
uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6.3.1
if: success() || failure() # always run even if the previous step fails
with:
report_paths: 'report.xml'
build:
name: Build (per-arch, native runners)
if: github.ref == 'refs/heads/develop'
@@ -154,7 +199,7 @@ jobs:
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
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile
@@ -190,13 +235,13 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -216,7 +261,7 @@ jobs:
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
- name: Build & Push (multi-arch, single tag)
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile

View File

@@ -42,15 +42,15 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
category: '/language:${{ matrix.language }}'

View File

@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Install git-cliff
uses: taiki-e/install-action@cede0bb282aae847dfa8aacca3a41c86d973d4d7 # v2.68.1
uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # v2.68.25
with:
tool: git-cliff
@@ -78,7 +78,7 @@ jobs:
- name: Commit updated files
run: |
git add package.json
git commit -m 'chore(release): prepare ${TAG_VERSION}'
git commit -m "chore(release): prepare ${TAG_VERSION}"
git push
- name: Create git tag

View File

@@ -42,7 +42,7 @@ jobs:
persist-credentials: false
- name: Run Lychee link checker
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0
with:
fail: false
args: >-

View File

@@ -40,7 +40,7 @@ jobs:
uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -105,7 +105,7 @@ jobs:
uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
- name: Downloads artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
@@ -114,7 +114,7 @@ jobs:
path: .cr-release-packages/
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -157,7 +157,7 @@ jobs:
persist-credentials: false
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
- name: Downloads artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
@@ -166,7 +166,7 @@ jobs:
path: .cr-release-packages/
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}

285
.github/workflows/pr-validation.yml vendored Normal file
View File

@@ -0,0 +1,285 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: "PR Validation"
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
semantic-title:
name: Validate PR Title
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
checks: write
issues: write
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
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 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')
);
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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
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 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,
});
}
template-check:
name: Validate PR Template
if: github.event.action != 'synchronize'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.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:
PR_BODY: ${{ github.event.pull_request.body }}
run: printf '%s' "$PR_BODY" > /tmp/pr-body.txt
- name: Run template check
if: steps.bot-check.outputs.skip != 'true'
id: check
env:
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
run: |
set +e
ISSUES=$(node bin/check-pr-template.mjs /tmp/pr-body.txt)
EXIT_CODE=$?
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
{
echo 'issues<<EOF'
printf '%s\n' "$ISSUES"
echo 'EOF'
} >> "$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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
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 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 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,
});
}
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.');
- name: Remove label on success
if: steps.bot-check.outputs.skip != 'true' && steps.check.outputs.exit_code == '0'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
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);
}
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:')
);
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}

View File

@@ -55,7 +55,7 @@ jobs:
echo "Building preview version: ${VER}"
- name: Warm cache (no push) — ${{ matrix.platform }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile
@@ -91,13 +91,13 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -127,7 +127,7 @@ jobs:
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
- name: Build & Push (multi-arch, single tag)
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile

View File

@@ -34,7 +34,7 @@ jobs:
- name: Generate changelog
id: git-cliff
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
uses: orhun/git-cliff-action@c93ef52f3d0ddcdcc9bd5447d98d458a11cd4f72 # v4.7.1
with:
config: .github/cliff.toml
args: -vv --current
@@ -88,7 +88,7 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Warm cache [${{ matrix.platform }}]
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile
@@ -127,13 +127,13 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -147,12 +147,14 @@ jobs:
${{ env.DOCKER_HUB }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ env.VERSION }}
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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile
@@ -206,19 +208,19 @@ jobs:
persist-credentials: false
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
- name: Install Trivy
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -267,7 +269,7 @@ jobs:
VERSION: ${{ github.ref_name }}
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
- name: Verify signatures
run: |

View File

@@ -1,28 +0,0 @@
name: "Semantic PR"
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
main:
name: Validate PR Title
runs-on: ubuntu-slim
permissions:
contents: read
pull-requests: read
checks: write
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
any-of-labels: "pending author's response"
exempt-issue-labels: 'confirmed'

View File

@@ -48,7 +48,7 @@ jobs:
trivy-${{ runner.os }}-
- name: Run Trivy image scan
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
with:
image-ref: ghcr.io/${{ github.repository }}:latest
format: sarif
@@ -56,6 +56,6 @@ jobs:
ignore-unfixed: true
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
sarif_file: trivy.sarif

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
# testing
/coverage
lcov.info
# next.js
/.next/

View File

@@ -16,6 +16,7 @@
"stylelint.vscode-stylelint",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
"firsttris.vscode-jest-runner"
]
}

View File

@@ -23,5 +23,12 @@
"i18n-ally.localesPaths": [
"src/i18n/locale"
],
"yaml.format.singleQuote": true
"yaml.format.singleQuote": true,
"jestrunner.enableTestExplorer": true,
"jestrunner.defaultTestPatterns": [
"server/**/*.{test,spec}.?(c|m)[jt]s?(x)",
],
"jestrunner.nodeTestCommand": "pnpm test",
"jestrunner.changeDirectoryToWorkspaceRoot": true,
"jestrunner.projectPath": "."
}

View File

@@ -10,43 +10,104 @@ All help is welcome and greatly appreciated! If you would like to contribute to
> This is an open-source project maintained by volunteers.
> We do not have the resources to review pull requests that could have been avoided with proper human oversight.
> While we have no issue with contributors using AI tools as an aid, it is your responsibility as a contributor to ensure that all submissions are carefully reviewed and meet our quality standards.
> Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban.
>
> If you are using **any kind of AI assistance** to contribute to Seerr,
> it must be disclosed in the pull request.
> **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.
>
> **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:
>
> - 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)
> - 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 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**, along with the extent to
which AI assistance was used (e.g. docs only vs. code generation).
If PR responses are being generated by an AI, disclose that as well.
**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.
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.
An example disclosure:
Example disclosures:
> This PR was written primarily by Claude Code.
Or a more detailed disclosure:
> I consulted ChatGPT to understand the codebase but the solution
> **AI Disclosure:** This PR was written primarily by Claude Code.
> **AI Disclosure:** I consulted ChatGPT to understand the codebase but the solution
> was fully authored manually by myself.
> **AI Disclosure:** None.
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.
When using AI assistance, we expect contributors to:
- **Understand the code** that is produced and be able to answer
questions about it.
- **Follow the contributing guide**. AI tools do not excuse you from
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.
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)!
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
isn't a maintainers job to review a PR so broken that it requires
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.
## Development
### Tools Required
@@ -202,4 +263,4 @@ DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate ser
## Attribution
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Ghostty](https://github.com/ghostty-org/ghostty) contribution guides.
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Ghostty](https://github.com/ghostty-org/ghostty) contribution guides. In addition, our AI policy draws from [Jellyfin's LLM policies](https://jellyfin.org/docs/general/contributing/llm-policies/).

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
FROM node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe AS base
ARG SOURCE_DATE_EPOCH
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
@@ -33,7 +33,7 @@ RUN pnpm build
RUN rm -rf .next/cache
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
FROM node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe
ARG SOURCE_DATE_EPOCH
ARG COMMIT_TAG
ENV NODE_ENV=production

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
FROM node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

92
bin/check-pr-template.mjs Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Validate that a pull request body follows the PR template.
*
*/
import { readFileSync } from 'fs';
import { resolve } from 'path';
const bodyFile = process.argv[2];
if (!bodyFile) {
console.error('body file path is required as an argument.');
process.exit(2);
}
const body = readFileSync(resolve(bodyFile), 'utf8');
const issues = [];
const MAINTAINER_ROLES = ['OWNER', 'MEMBER', 'COLLABORATOR'];
const isMaintainer = MAINTAINER_ROLES.includes(
process.env.AUTHOR_ASSOCIATION ?? ''
);
const stripComments = (s) => {
let result = s;
let previous;
do {
previous = result;
result = result.replace(/<!--[\s\S]*?-->/g, '');
} while (result !== previous);
return result;
};
const stripFixesPlaceholder = (s) => s.replace(/-\s*Fixes\s*`?#XXXX`?/gi, '');
const descriptionMatch = body.match(/## Description\s*\n([\s\S]*?)(?=\n## |$)/);
const descriptionContent = descriptionMatch
? stripFixesPlaceholder(stripComments(descriptionMatch[1])).trim()
: '';
if (!descriptionContent) {
issues.push(
'**Description** section is empty or only contains placeholder text.'
);
}
const testingMatch = body.match(
/## How Has This Been Tested\?\s*\n([\s\S]*?)(?=\n## |$)/
);
const testingContent = testingMatch
? stripComments(testingMatch[1]).trim()
: '';
if (!testingContent) {
issues.push('**How Has This Been Tested?** section is empty.');
}
const checklistMatch = body.match(/## Checklist:\s*\n([\s\S]*?)$/);
const checklistContent = checklistMatch ? checklistMatch[1] : '';
const totalBoxes = (checklistContent.match(/- \[[ x]\]/gi) || []).length;
const checkedBoxes = (checklistContent.match(/- \[x\]/gi) || []).length;
if (totalBoxes === 0) {
issues.push('**Checklist** section is missing or has been removed.');
} else if (checkedBoxes === 0) {
issues.push(
'No items in the **checklist** have been checked. Please review and check all applicable items.'
);
}
if (
!/- \[x\] I have read and followed the contribution/i.test(checklistContent)
) {
issues.push('The **contribution guidelines** checkbox has not been checked.');
}
if (
!isMaintainer &&
!/- \[x\] Disclosed any use of AI/i.test(checklistContent)
) {
issues.push('The **AI disclosure** checkbox has not been checked.');
}
if (/-\s*Fixes\s*`?#XXXX`?/i.test(body)) {
issues.push(
'The `Fixes #XXXX` placeholder has not been updated. Please link the relevant issue or remove it.'
);
}
console.log(JSON.stringify(issues));
process.exit(issues.length > 0 ? 1 : 0);

View File

@@ -3,9 +3,9 @@ kubeVersion: '>=1.23.0-0'
name: seerr-chart
description: Seerr helm chart for Kubernetes
type: application
version: 3.2.0
version: 3.4.2
# renovate: image=ghcr.io/seerr-team/seerr
appVersion: 'v3.0.1'
appVersion: 'v3.1.1'
maintainers:
- name: Seerr Team
url: https://github.com/orgs/seerr-team/people

View File

@@ -1,6 +1,6 @@
# seerr-chart
![Version: 3.2.0](https://img.shields.io/badge/Version-3.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.0.1](https://img.shields.io/badge/AppVersion-v3.0.1-informational?style=flat-square)
![Version: 3.4.2](https://img.shields.io/badge/Version-3.4.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.1.1](https://img.shields.io/badge/AppVersion-v3.1.1-informational?style=flat-square)
Seerr helm chart for Kubernetes
@@ -44,12 +44,13 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| affinity | object | `{}` | |
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"existingClaim":"","name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"existingClaim":"","name":"","size":"5Gi","subPath":"","volumeName":""}}` | Creating PVC to store configuration |
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
| config.persistence.existingClaim | string | `""` | Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created |
| config.persistence.name | string | `""` | Config name |
| config.persistence.size | string | `"5Gi"` | Size of persistent disk |
| config.persistence.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. |
| extraEnv | list | `[]` | Environment variables to add to the seerr pods |
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the seerr pods |
@@ -73,8 +74,8 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| podLabels | object | `{}` | |
| podSecurityContext.fsGroup | int | `1000` | |
| podSecurityContext.fsGroupChangePolicy | string | `"OnRootMismatch"` | |
| probes.livenessProbe | object | `{}` | Configure liveness probe |
| probes.readinessProbe | object | `{}` | Configure readiness probe |
| 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 |
| resources | object | `{}` | |
| route.main.additionalRules | list | `[]` | |

View File

@@ -44,7 +44,7 @@ spec:
protocol: TCP
livenessProbe:
httpGet:
path: /
path: /api/v1/settings/public
port: http
{{- if .Values.probes.livenessProbe.initialDelaySeconds }}
initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }}
@@ -63,7 +63,7 @@ spec:
{{- end }}
readinessProbe:
httpGet:
path: /
path: /api/v1/settings/public
port: http
{{- if .Values.probes.readinessProbe.initialDelaySeconds }}
initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }}
@@ -97,6 +97,7 @@ spec:
volumeMounts:
- name: config
mountPath: /app/config
subPath: {{ .Values.config.persistence.subPath }}
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}

View File

@@ -13,19 +13,19 @@ fullnameOverride: ''
# Liveness / Readiness / Startup Probes
probes:
# -- Configure liveness probe
livenessProbe: {}
# initialDelaySeconds: 60
# periodSeconds: 30
# timeoutSeconds: 5
# successThreshold: 1
# failureThreshold: 5
livenessProbe:
initialDelaySeconds: 20
periodSeconds: 15
timeoutSeconds: 3
# successThreshold: 1
# failureThreshold: 3
# -- Configure readiness probe
readinessProbe: {}
# initialDelaySeconds: 60
# periodSeconds: 30
# timeoutSeconds: 5
# successThreshold: 1
# failureThreshold: 5
readinessProbe:
initialDelaySeconds: 60
periodSeconds: 15
timeoutSeconds: 3
# successThreshold: 1
# failureThreshold: 3
# -- Configure startup probe
startupProbe: null
# tcpSocket:
@@ -88,6 +88,8 @@ config:
volumeName: ''
# -- Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created
existingClaim: ''
# -- Subpath of the pvc which should be mounted
subPath: ''
ingress:
enabled: false

View File

@@ -1,5 +1,6 @@
{
"clientId": "6919275e-142a-48d8-be6b-93594cbd4626",
"sessionSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
"main": {

View File

@@ -36,7 +36,7 @@ describe('User List', () => {
cy.get('#email').type(testUser.emailAddress);
cy.get('#password').type(testUser.password);
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
cy.intercept('/api/v1/user*').as('user');
cy.get('[data-testid=modal-ok-button]').click();
@@ -56,7 +56,7 @@ describe('User List', () => {
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
cy.intercept('/api/v1/user*').as('user');
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
@@ -67,4 +67,42 @@ describe('User List', () => {
.contains(testUser.emailAddress)
.should('not.exist');
});
it('sorts by column headers and updates request params and row order', () => {
cy.intercept('GET', '/api/v1/user?*').as('userListFetch');
cy.visit('/users');
cy.wait('@userListFetch');
cy.get('[data-testid=column-header-displayname]').click();
cy.wait('@userListFetch').then((interception) => {
const url = interception.request.url;
expect(url).to.include('sort=displayname');
expect(url).to.include('sortDirection=asc');
});
cy.get(
'[data-testid=user-list-row] [data-testid=user-list-username-link]'
).then(($links) => {
const displayNames = $links
.toArray()
.map((el) => (el as HTMLElement).innerText.trim().toLowerCase());
const sortedAsc = [...displayNames].sort((a, b) => a.localeCompare(b));
expect(displayNames).to.deep.equal(sortedAsc);
});
cy.get('[data-testid=column-header-created]').click();
cy.window().then((win) => {
const rawSettings = win.localStorage.getItem('ul-filter-settings');
expect(
rawSettings,
'ul-filter-settings should be stored in localStorage'
).to.be.a('string');
const settings = JSON.parse(rawSettings as string);
expect(settings.currentSort).to.equal('created');
expect(settings.sortDirection).to.equal('asc');
});
});
});

View File

@@ -293,7 +293,7 @@ This is a third-party documentation maintained by the community. We can't provid
log global
option httplog
http-response set-header Strict-Transport-Security max-age=15552000
option httpchk GET /api/v1/status
option httpchk GET /api/v1/settings/public
timeout connect 30000
timeout server 30000
retries 3

View File

@@ -157,7 +157,7 @@ npm install -g pm2
```
2. Start seerr with PM2:
```bash
pm2 start dist/index.js --name seerr --node-args="--NODE_ENV=production"
NODE_ENV=production pm2 start dist/index.js --name seerr
```
3. Save the process list:
```bash
@@ -206,7 +206,7 @@ git checkout main
3. Install the dependencies:
```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
$env:CYPRESS_INSTALL_BINARY = 0; pnpm install --frozen-lockfile
```
4. Build the project:
```powershell
@@ -230,7 +230,7 @@ You can now access Seerr by visiting `http://localhost:5055` in your web browser
<TabItem value="task-scheduler" label="Task Scheduler">
To run seerr as a bat script:
1. Create a file named `start-seerr.bat` in the seerr directory:
```bat
```batch
@echo off
set PORT=5055
set NODE_ENV=production
@@ -275,7 +275,7 @@ npm install -g pm2
```
2. Start seerr with PM2:
```powershell
pm2 start dist/index.js --name seerr --node-args="--NODE_ENV=production"
$env:NODE_ENV = "production"; pm2 start dist/index.js --name seerr
```
3. Save the process list:
```powershell

View File

@@ -18,6 +18,8 @@ Our Docker images are available with the following tags:
- `latest`: Always points to the most recent stable release.
- Version tags (e.g., `v3.0.0`): For specific stable versions.
- Major version aliases (e.g., `v3`): Alias for the latest stable release in the respective major version series.
- Minor version aliases (e.g., `v3.0`): Alias for the latest stable release in the respective minor version series.
- `develop`: Rolling release/nightly builds for using the latest changes (use with caution).
:::
@@ -61,7 +63,7 @@ docker run -d \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1" \
--health-start-period 20s \
--health-timeout 3s \
--health-interval 15s \
@@ -116,7 +118,7 @@ services:
volumes:
- /path/to/appdata/config:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1
start_period: 20s
timeout: 3s
interval: 15s
@@ -163,41 +165,85 @@ docker volume create seerr-data
or the Docker Desktop app:
1. Open the Docker Desktop app
2. Head to the Volumes tab
3. Click on the "New Volume" button near the top right
3. Click on the "Create a volume" in the center of the page. Or if you have other volumes already, click on the "Create" button on the top right of the page.
4. Enter a name for the volume (example: `seerr-data`) and hit "Create"
Then, create and start the Seerr container:
<Tabs groupId="docker-methods" queryString>
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d \
--name seerr \
--init \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tashkent \
-e PORT=5055 \
-p 5055:5055 \
-v seerr-data:/app/config \
--restart unless-stopped \
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
--health-start-period 20s \
--health-timeout 3s \
--health-interval 15s \
--health-retries 3 \
<TabItem value="docker-cli" label="Docker CLI (PowerShell)">
```powershell
docker run -d `
--name seerr `
--init `
-e LOG_LEVEL=debug `
-e TZ=Asia/Tashkent `
-e PORT=5055 `
-p 5055:5055 `
-v seerr-data:/app/config `
--restart unless-stopped `
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1" `
--health-start-period 20s `
--health-timeout 3s `
--health-interval 15s `
--health-retries 3 `
ghcr.io/seerr-team/seerr:latest
```
The argument `-e PORT=5055` is optional.
#### Updating:
Stop and remove the existing container:
```powershell
docker stop seerr; docker rm seerr
```
Pull the latest image:
```bash
docker compose pull seerr
```powershell
docker pull ghcr.io/seerr-team/seerr:latest
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
Finally, run the container with the same parameters originally used to create the container:
```powershell
docker run -d ...
```
</TabItem>
<TabItem value="docker-cli-cmd" label="Docker CLI (CMD)">
```batch
docker run -d ^
--name seerr ^
--init ^
-e LOG_LEVEL=debug ^
-e TZ=Asia/Tashkent ^
-e PORT=5055 ^
-p 5055:5055 ^
-v seerr-data:/app/config ^
--restart unless-stopped ^
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1" ^
--health-start-period 20s ^
--health-timeout 3s ^
--health-interval 15s ^
--health-retries 3 ^
ghcr.io/seerr-team/seerr:latest
```
The argument `-e PORT=5055` is optional.
#### Updating:
Stop and remove the existing container:
```batch
docker stop seerr && docker rm seerr
```
Pull the latest image:
```batch
docker pull ghcr.io/seerr-team/seerr:latest
```
Finally, run the container with the same parameters originally used to create the container:
```batch
docker run -d ...
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
@@ -216,7 +262,7 @@ services:
volumes:
- seerr-data:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1
start_period: 20s
timeout: 3s
interval: 15s

View File

@@ -0,0 +1,96 @@
---
title: Nix Package Manager (Advanced)
description: Install Seerr using Nixpkgs
sidebar_position: 4
---
import { SeerrVersion, NixpkgVersion } from '@site/src/components/SeerrVersion';
import Admonition from '@theme/Admonition';
import Tabs from '@theme/Tabs';
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.
:::
:::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)
<!--
export const VersionMismatchWarning = () => {
let seerrVersion = null;
let nixpkgVersions = null;
try {
seerrVersion = SeerrVersion();
nixpkgVersions = NixpkgVersion();
} catch (err) {
return (
<Admonition type="error">
Failed to load version information. Error: {err.message || JSON.stringify(err)}
</Admonition>
);
}
if (!nixpkgVersions || nixpkgVersions.error) {
return (
<Admonition type="error">
Failed to fetch Nixpkg versions: {nixpkgVersions?.error || 'Unknown error'}
</Admonition>
);
}
const isUnstableUpToDate = seerrVersion === nixpkgVersions.unstable;
const isStableUpToDate = seerrVersion === nixpkgVersions.stable;
return (
<>
{!isStableUpToDate ? (
<Admonition type="warning">
The{' '}
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-26.05/pkgs/by-name/se/seerr/package.nix#L23">
upstream Seerr Nix Stable Package (v{nixpkgVersions.stable})
</a>{' '}
is not <b>up-to-date</b>. If you want to use <b>Seerr v{seerrVersion}</b>,{' '}
{isUnstableUpToDate ? (
<>
consider using the{' '}
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/se/seerr/package.nix">
unstable package
</a>{' '}
instead.
</>
) : (
<>
you will need to{' '} override the package derivation.
</>
)}
</Admonition>
) : null}
</>
);
};
<VersionMismatchWarning />
-->
## Installation
To get up and running with seerr using Nix, you can add the following to your `configuration.nix`:
```nix
{ config, pkgs, ... }:
{
services.seerr.enable = true;
}
```
After adding the configuration to your `configuration.nix`, you can run the following command to install seerr:
```bash
nixos-rebuild switch
```
After rebuild is complete seerr should be running, verify that it is with the following command.
```bash
systemctl status seerr
```
:::info
You can now access Seerr by visiting `http://localhost:5055` in your web browser.
:::

View File

@@ -1,7 +1,7 @@
---
title: AUR (Advanced)
description: Install Seerr using the Arch User Repository
sidebar_position: 2
sidebar_position: 1
---
# AUR

View File

@@ -1,30 +0,0 @@
---
title: Nix Package Manager (Advanced)
description: Install Seerr using Nixpkgs
sidebar_position: 1
---
import { SeerrVersion, NixpkgVersion } from '@site/src/components/SeerrVersion';
import Admonition from '@theme/Admonition';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Nix Package Manager
:::danger
This method has not yet been updated for Seerr and is currently a work in progress.
You can follow the ongoing work on these pull requests:
- https://github.com/NixOS/nixpkgs/pull/450096
- https://github.com/NixOS/nixpkgs/pull/450093
:::
<!--
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
:::warning
This method is not recommended for most users. It is intended for advanced users who are using NixOS distribution.
:::
Refer to [NixOS documentation](https://search.nixos.org/options?channel=25.05&query=seerr)
-->

View File

@@ -1,7 +1,7 @@
---
title: Synology (Advanced)
description: Install Seerr on Synology NAS using SynoCommunity
sidebar_position: 5
sidebar_position: 4
---
# Synology

View File

@@ -1,7 +1,7 @@
---
title: TrueNAS (Advanced)
description: Install Seerr using TrueNAS
sidebar_position: 4
sidebar_position: 3
---
# TrueNAS
:::warning

View File

@@ -1,7 +1,7 @@
---
title: Unraid (Advanced)
description: Install Seerr using Unraid
sidebar_position: 3
sidebar_position: 2
---
import Tabs from '@theme/Tabs';

View File

@@ -39,6 +39,8 @@ Our Docker images are available with the following tags:
- `latest`: Always points to the most recent stable release.
- Version tags (e.g., `v3.0.0`): For specific stable versions.
- Major version aliases (e.g., `v3`): Alias for the latest stable release in the respective major version series.
- Minor version aliases (e.g., `v3.0`): Alias for the latest stable release in the respective minor version series.
- `develop`: Rolling release/nightly builds for using the latest changes (use with caution).
:::
@@ -53,6 +55,7 @@ Changes :
Since the container now runs as the `node` user (UID 1000), you must ensure your config folder has the correct permissions. The `node` user must have read and write access to the `/app/config` directory.
If you're migrating from a previous installation, you may need to update the ownership of your config folder:
```bash
docker run --rm -v /path/to/appdata/config:/data alpine chown -R 1000:1000 /data
```
@@ -81,7 +84,7 @@ Summary of changes :
volumes:
- /path/to/appdata/config:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1
start_period: 20s
timeout: 3s
interval: 15s
@@ -106,6 +109,7 @@ Summary of changes :
</Tabs>
### Windows
Summary of changes :
<Tabs groupId="docker-methods" queryString>
<TabItem value="docker-compose" label="Docker compose">
@@ -124,7 +128,7 @@ Summary of changes :
volumes:
- seerr-data:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1
start_period: 20s
timeout: 3s
interval: 15s
@@ -136,17 +140,32 @@ Summary of changes :
external: true
```
</TabItem>
<TabItem value="docker-cli" label="Docker CLI">
```bash {2-3,8,10}
docker run -d \
--name seerr \
--init \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tashkent \
-e PORT=5055 \
-p 5055:5055 \
-v seerr-data:/app/config \
--restart unless-stopped \
<TabItem value="docker-cli" label="Docker CLI (PowerShell)">
```powershell {2-3,8,10}
docker run -d `
--name seerr `
--init `
-e LOG_LEVEL=debug `
-e TZ=Asia/Tashkent `
-e PORT=5055 `
-p 5055:5055 `
-v seerr-data:/app/config `
--restart unless-stopped `
ghcr.io/seerr-team/seerr:latest
```
</TabItem>
<TabItem value="docker-cli-cmd" label="Docker CLI (CMD)">
```batch {2-3,8,10}
docker run -d ^
--name seerr ^
--init ^
-e LOG_LEVEL=debug ^
-e TZ=Asia/Tashkent ^
-e PORT=5055 ^
-p 5055:5055 ^
-v seerr-data:/app/config ^
--restart unless-stopped ^
ghcr.io/seerr-team/seerr:latest
```
</TabItem>
@@ -199,16 +218,18 @@ Summary of changes :
</TabItem>
</Tabs>
## Nix
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.
## Third-party installation methods
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
### Nix
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
### AUR
See https://aur.archlinux.org/packages/seerr
@@ -277,4 +298,4 @@ Follow the [Unraid Installation Guide](/getting-started/third-parties/unraid#2-s
Start the newly created Seerr container. Check the container logs to confirm the automatic migration completed successfully.
**5. Remove the old app**
Once you have confirmed Seerr is working properly and your data has successfully migrated, you can safely **Remove** the old Overseerr (or Jellyseerr) container from Unraid.
Once you have confirmed Seerr is working properly and your data has successfully migrated, you can safely **Remove** the old Overseerr (or Jellyseerr) container from Unraid.

View File

@@ -0,0 +1,61 @@
---
id: self-signed-certificates
title: Self-Signed Certificates
sidebar_label: Self-Signed Certificates
description: How to configure Seerr to work with services that use self-signed SSL certificates.
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Self-Signed Certificates
If your media server or services (Radarr, Sonarr, etc.) use self-signed SSL certificates, Seerr will reject the connection because it does not trust them by default. The fix is to add your CA certificate to Node.js.
## Add Your CA Certificate
The `NODE_EXTRA_CA_CERTS` environment variable tells Node.js to trust additional Certificate Authority (CA) certificates. This approach keeps certificate validation active while trusting your specific certificate.
You will need to mount your certificate file (in PEM format) into the container and set the environment variable to point to it.
:::note
These examples show only the certificate-related configuration. For a complete setup, see the [Getting Started](/getting-started) guide.
:::
<Tabs>
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d \
--name seerr \
-e NODE_EXTRA_CA_CERTS=/certs/my-ca.pem \
-v /path/to/my-ca.pem:/certs/my-ca.pem:ro \
-p 5055:5055 \
ghcr.io/seerr-team/seerr:latest
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
```yaml
services:
seerr:
image: ghcr.io/seerr-team/seerr:latest
environment:
- NODE_EXTRA_CA_CERTS=/certs/my-ca.pem
volumes:
- /path/to/my-ca.pem:/certs/my-ca.pem:ro
ports:
- 5055:5055
```
</TabItem>
</Tabs>
Replace `/path/to/my-ca.pem` with the actual path to your CA certificate on the host. The path after the colon (`/certs/my-ca.pem`) is where it will be available inside the container.
:::tip
The certificate must be in PEM format. Open it in a text editor — if it starts with `-----BEGIN CERTIFICATE-----`, it is PEM. If it contains binary data, convert it with `openssl x509 -inform DER -in cert.cer -out cert.pem`.
:::
For more details, see the [Node.js documentation on adding CA certificates](https://nodejs.org/en/learn/http/enterprise-network-configuration#adding-additional-ca-certificates).

View File

@@ -24,6 +24,10 @@ Set this to the username and password for your ntfy.sh server.
Set this to the token for your ntfy.sh server.
### Priority (optional)
Set the priority level for notifications. Options range from Minimum (1) to Urgent (5), with Default (3) being the standard level. Higher priority notifications may bypass Do Not Disturb settings on some devices.
:::info
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
:::

View File

@@ -22,6 +22,17 @@ This is typically not needed. Please refer to your webhook provider's documentat
This value will be sent as an `Authorization` HTTP header.
### Custom Headers (optional)
You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires.
- Click "Add Header" to add a new header
- Enter the header name and value
:::warning
You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method.
:::
### JSON Payload
Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
@@ -84,13 +95,15 @@ The `{{media}}` will be `null` if there is no relevant media object for the noti
These following special variables are only included in media-related notifications, such as requests.
| Variable | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_tmdbid}}` | The media's TMDB ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| Variable | Value |
| ------------------------------| -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_imdbid}}` | The media's IMDb ID |
| `{{media_tmdbid}}` | The media's TMDB ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_jellyfinMediaId}}` | The media's Jellyfin Media ID |
#### Request
@@ -104,6 +117,7 @@ The following special variables are only included in request-related notificatio
| `{{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) |

View File

@@ -1,16 +0,0 @@
---
title: DNS Caching
description: Configure DNS caching settings.
sidebar_position: 7
---
# DNS Caching
Seerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
## Configuration
You can enable the DNS caching settings in the Network tab of the Seerr settings. The default values follow the standard DNS caching behavior.
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).

View File

@@ -24,28 +24,6 @@ Set this to the externally-accessible URL of your Seerr instance.
You must configure this setting in order to enable password reset and generation emails.
## Enable Proxy Support
If you have Seerr behind a reverse proxy, enable this setting to allow Seerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html).
This setting is **disabled** by default.
## Enable CSRF Protection
:::warning
**This is an advanced setting.** Please only enable this setting if you are familiar with CSRF protection and how it works.
:::
CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Seerr application data is blocked.
If you do not use Seerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
One caveat, however, is that HTTPS is required, meaning that once this setting is enabled, you will no longer be able to access your Seerr instance over _HTTP_ (including using an IP address and port number).
If you enable this setting and find yourself unable to access Seerr, you can disable the setting by modifying `settings.json` in `/app/config`.
This setting is **disabled** by default.
## Enable Image Caching
When enabled, Jellseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
@@ -62,6 +40,15 @@ Set the default display language for Seerr. Users can override this setting in t
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
## Blocklist Region and Blocklist Language
These settings control the region and language used specifically for blocklist content scanning. The "Process Blocklisted Tags" job uses these settings to determine which content to scan for blocklisting, independent of the general Discover settings.
- **Blocklist Region**: The region used for blocklist content scanning. Leave empty to scan all regions.
- **Blocklist Language**: The language used for blocklist content scanning. Leave empty to scan all languages.
These settings are separate from the general "Discover Region" and "Discover Language" settings, allowing you to blocklist content from specific regions/languages regardless of what users see in their Discover pages.
## Blocklist Content with Tags and Limit Content Blocklisted per Tag
These settings blocklist any TV shows or movies that have one of the entered tags. The "Process Blocklisted Tags" job adds entries to the blocklist based on the configured blocklisted tags. If a blocklisted tag is removed, any media blocklisted under that tag will be removed from the blocklist when the "Process Blocklisted Tags" job runs.

View File

@@ -84,7 +84,7 @@ This value should be set to the port that your Jellyfin server listens on. The d
#### Use SSL
Enable this setting to connect to Jellyfin via HTTPS rather than HTTP. Note that self-signed certificates are **not** officially supported.
Enable this setting to connect to Jellyfin via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### External URL
@@ -178,7 +178,7 @@ This value should be set to the port that your Emby server listens on. The defau
#### Use SSL
Enable this setting to connect to Emby via HTTPS rather than HTTP. Note that self-signed certificates are **not** officially supported.
Enable this setting to connect to Emby via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### External URL
@@ -218,7 +218,7 @@ This value should be set to the port that your Plex server listens on. The defau
#### Use SSL
Enable this setting to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
Enable this setting to connect to Plex via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### Web App URL (optional)

View File

@@ -0,0 +1,60 @@
---
title: Network
description: Configure Network settings.
sidebar_position: 7
---
# Network
Network-related settings are available in the **Network** tab under **Settings**. These options control how Seerr communicates with external services
## DNS Caching
Seerr allows you to enable DNS caching if you are experiencing DNS-related issues. When enabled, it improves performance and reduces the number of DNS lookups required for external API calls. This can help speed up response times and reduce the load on DNS servers, especially when a local resolver like Pi-hole is used.
### Configuration
You can enable the DNS caching settings in the Network tab of the Seerr settings. The default values follow the standard DNS caching behavior.
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).
## Force IPv4 resolution first
Sometimes there are configuration issues with IPv6 that prevent the hostname resolution from working correctly.
You can force resolution to prefer IPv4 by going to `Settings > Network`, enabling `Force IPv4 Resolution First`, and then restarting Seerr.
## HTTP(S) Proxy
If you can't change your DNS servers or force IPV4 resolution, you can use Seerr through a proxy.
In some places (like China), the ISP blocks not only the DNS resolution but also the connection to the TMDB API.
## Enable Proxy Support
If you have Seerr behind a reverse proxy, enable this setting to allow Seerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html).
This setting is **disabled** by default.
## Enable CSRF Protection
:::warning
**This is an advanced setting.** Please only enable this setting if you are familiar with CSRF protection and how it works.
:::
CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Seerr application data is blocked.
If you do not use Seerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
One caveat, however, is that HTTPS is required, meaning that once this setting is enabled, you will no longer be able to access your Seerr instance over _HTTP_ (including using an IP address and port number).
If you enable this setting and find yourself unable to access Seerr, you can disable the setting by modifying `settings.json` in `/app/config`.
This setting is **disabled** by default.
## API Request Timeout
The API Request Timeout setting defines the maximum time (in seconds) Seerr will wait for a response from external services, such as Radarr or Sonarr. The default value is 10 seconds, though it can be entirely disabled by setting it to 0. Please note that any changes to this value require restarting Seerr to take effect.
Enforcing a timeout ensures the Seerr interface remains responsive and prevents infinite loading states when a connected service unexpectedly goes offline. Conversely, you may want to increase this value if you frequently experience failed requests due to your external services being slow to respond, which often happens when they are under heavy load or querying network-mounted storage.

View File

@@ -44,7 +44,7 @@ This value should be set to the port that your Radarr/Sonarr server listens on.
#### Use SSL
Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### API Key

95
eslint.config.mts Normal file
View File

@@ -0,0 +1,95 @@
import js from '@eslint/js';
import nextPlugin from '@next/eslint-plugin-next';
import prettier from 'eslint-config-prettier';
import formatjs from 'eslint-plugin-formatjs';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig, type Config } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
type Plugin = NonNullable<Config['plugins']>[string];
export default defineConfig(
// Global ignores
{
ignores: ['node_modules/**', '.next/**'],
},
js.configs.recommended,
tseslint.configs.recommended,
jsxA11y.flatConfigs.recommended,
{
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.node,
...globals.jest,
},
},
settings: {
react: {
pragma: 'React',
version: '18.3',
},
},
plugins: {
react: reactPlugin,
'react-hooks': reactHooks as Plugin,
formatjs,
'no-relative-import-paths': noRelativeImportPaths,
'@next/next': nextPlugin,
},
rules: {
...nextPlugin.configs.recommended.rules,
// TypeScript
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array' }],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports' },
],
// React
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
// jsx-a11y
'jsx-a11y/no-noninteractive-tabindex': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'jsx-a11y/no-onchange': 'off',
// React Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// General
'arrow-parens': 'off',
'no-console': 'warn',
'no-unused-vars': 'off',
// Plugins
'formatjs/no-offset': 'error',
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: true },
],
},
},
prettier,
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
}
);

View File

@@ -0,0 +1,72 @@
---
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]
image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg
hide_table_of_contents: false
---
We are releasing **Seerr v3.1.0**, a security-focused update that addresses three CVEs, including a high-priority vulnerability affecting instances configured with Plex Media Server. **We strongly recommend upgrading as soon as possible.**
This release also includes a number of bug fixes and marks the end of our post-merger feature freeze. New features will be resuming in future updates.
<!--truncate-->
## Security Vulnerabilities
This release patches three newly identified CVEs. If you are running a Plex-configured instance of Seerr, **one of these vulnerabilities is high priority and poses a significant risk**, please upgrade immediately.
### [CVE-2026-27707](https://github.com/seerr-team/seerr/security/advisories/GHSA-rc4w-7m3r-c2f7) — Unauthenticated Account Registration via Jellyfin Endpoint (High)
On instances configured to use Plex as the media server, an unauthenticated attacker could register an account by abusing the Jellyfin authentication endpoint. This could allow unauthorized users to gain access to your Seerr instance without valid Plex credentials.
### [CVE-2026-27793](https://github.com/seerr-team/seerr/security/advisories/GHSA-f7xw-jcqr-57hp) — Broken Object-Level Authorization in User Profile Endpoint (Medium)
A broken object-level authorization vulnerability in the user profile endpoint could allow an authenticated user to access another user's profile data, including third-party notification credentials such as webhook URLs, Telegram tokens, and similar sensitive configuration.
### [CVE-2026-27792](https://github.com/seerr-team/seerr/security/advisories/GHSA-gx3h-3jg5-q65f) — Missing Authentication on Push Subscription Endpoints (Medium)
The push subscription endpoints lacked proper authentication checks, allowing unauthenticated requests to interact with subscription management functionality.
---
Please review the full security advisories linked above for technical details, impact assessment, and mitigation steps.
## Bug Fixes
Alongside the security patches, this release ships a number of bug fixes:
- ***(helm)*** Add `"v"` as prefix for `appVersion` tag
- ***(jellyfin-scanner)*** Include unmatched seasons in processable seasons
- ***(link-account)*** Fix error-message override
- ***(plex-scanner)*** Add TVDb to TMDB fallback in Plex scanner
- ***(radarr)*** Trigger search for existing monitored movies without files
- ***(servarr)*** Increase default API timeout from 5000ms to 10000ms
- ***(sonarr)*** Use configured metadata provider for season filtering
- ***(watch-data)*** Use sentinel values to avoid invalid SQL syntax
- ***(watchlist-sync)*** Correct permission typo for TV auto requests
- Preserve blocklist on media deletion & optimise watchlist-sync
## New Contributors
Many thanks to those making their first contribution to Seerr in this release:
* [@caillou](https://github.com/caillou)
* [@Kenshin9977](https://github.com/Kenshin9977)
* [@MagicLegend](https://github.com/MagicLegend)
* [@wiiaam](https://github.com/wiiaam)
* [@mjonkus](https://github.com/mjonkus)
* [@nova-api](https://github.com/nova-api)
* [@mreid-tt](https://github.com/mreid-tt)
* [@DataBitz](https://github.com/DataBitz)
* [@Hyperion2220](https://github.com/Hyperion2220)
* [@blassley](https://github.com/blassley)
* [@JanKleine](https://github.com/JanKleine)
* [@koiralasandesh](https://github.com/koiralasandesh)
## What's Next
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.

View File

@@ -133,7 +133,14 @@ const config: Config = {
prism: {
theme: prismThemes.shadesOfPurple,
darkTheme: prismThemes.shadesOfPurple,
additionalLanguages: ['bash', 'powershell', 'yaml', 'nix', 'nginx'],
additionalLanguages: [
'bash',
'powershell',
'yaml',
'nix',
'nginx',
'batch',
],
},
} satisfies Preset.ThemeConfig,
};

View File

@@ -34,9 +34,9 @@ export const NixpkgVersion = () => {
const fetchVersion = async () => {
try {
const unstableUrl =
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix';
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/se/seerr/package.nix';
const stableUrl =
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-25.05/pkgs/by-name/je/jellyseerr/package.nix';
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-26.05/pkgs/by-name/se/seerr/package.nix';
const [unstableResponse, stableResponse] = await Promise.all([
fetch(unstableUrl),

View File

@@ -12,6 +12,7 @@
"build": "pnpm build:next && pnpm build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
"test": "node server/test/index.mts",
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
@@ -43,7 +44,7 @@
"@heroicons/react": "2.2.0",
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@svgr/webpack": "8.1.0",
"@tanem/react-nprogress": "5.0.56",
"@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2",
@@ -117,6 +118,8 @@
"devDependencies": {
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@eslint/js": "9.39.3",
"@next/eslint-plugin-next": "^16.1.6",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
@@ -124,7 +127,8 @@
"@types/cookie-parser": "1.4.10",
"@types/country-flag-icons": "1.2.2",
"@types/csurf": "1.11.5",
"@types/email-templates": "8.0.4",
"@types/email-templates": "10.0.4",
"@types/eslint-plugin-jsx-a11y": "^6.10.1",
"@types/express": "4.17.17",
"@types/express-session": "1.18.2",
"@types/lodash": "4.17.21",
@@ -137,45 +141,48 @@
"@types/react-transition-group": "4.4.12",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.7.1",
"@types/supertest": "^6.0.3",
"@types/swagger-ui-express": "4.1.8",
"@types/validator": "^13.15.10",
"@types/web-push": "3.6.4",
"@types/xml2js": "0.4.14",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"autoprefixer": "^10.4.23",
"baseline-browser-mapping": "^2.8.32",
"commander": "^14.0.3",
"commitizen": "4.3.1",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "14.5.4",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.57.1",
"eslint-config-next": "^14.2.35",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0",
"eslint": "9.39.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-formatjs": "6.2.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-hooks": "7.0.1",
"globals": "^17.3.0",
"husky": "8.0.3",
"jiti": "^2.6.1",
"lint-staged": "13.1.2",
"nodemon": "3.1.11",
"postcss": "^8.5.6",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"prettier-plugin-tailwindcss": "0.6.14",
"supertest": "^7.2.2",
"tailwindcss": "3.4.19",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.4.5"
"typescript": "5.4.5",
"typescript-eslint": "^8.56.1"
},
"engines": {
"node": "^22.0.0",
"node": "^22.19.0",
"pnpm": "^10.0.0"
},
"config": {

9036
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -706,10 +706,18 @@ components:
example: A Label
PublicSettings:
type: object
required:
- initialized
- plexClientIdentifier
properties:
initialized:
type: boolean
example: false
plexClientIdentifier:
type: string
format: uuid
description: Instance Plex OAuth client identifier
example: 6919275e-142a-48d8-be6b-93594cbd4626
MovieResult:
type: object
required:
@@ -4117,8 +4125,16 @@ paths:
name: sort
schema:
type: string
enum: [created, updated, requests, displayname]
enum: [created, updated, requests, displayname, usertype, role]
default: created
- in: query
name: sortDirection
description: |
Sort direction. When omitted, the server chooses the direction per sort
field (e.g. displayname defaults to asc, requests/updated to desc).
schema:
type: string
enum: [asc, desc]
- in: query
name: q
required: false
@@ -4620,6 +4636,14 @@ paths:
example: '1'
schema:
type: string
- in: query
name: mediaType
required: true
schema:
type: string
enum:
- movie
- tv
responses:
'200':
description: Blocklist details in JSON
@@ -4635,6 +4659,14 @@ paths:
example: '1'
schema:
type: string
- in: query
name: mediaType
required: true
schema:
type: string
enum:
- movie
- tv
responses:
'204':
description: Succesfully removed media item
@@ -4737,6 +4769,14 @@ paths:
example: '1'
schema:
type: string
- in: query
name: mediaType
required: true
schema:
type: string
enum:
- movie
- tv
responses:
'200':
description: Blocklist details in JSON
@@ -4755,9 +4795,60 @@ paths:
example: '1'
schema:
type: string
- in: query
name: mediaType
required: true
schema:
type: string
enum:
- movie
- tv
responses:
'204':
description: Succesfully removed media item
/blocklist/collection/{collectionId}:
post:
summary: Add collection to blocklist
description: Adds all movies in a collection to the blocklist
tags:
- blocklist
parameters:
- in: path
name: collectionId
description: Collection ID
required: true
example: '1424991'
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
type: object
responses:
'201':
description: Successfully added collection to blocklist
'500':
description: Error adding collection to blocklist
delete:
summary: Remove collection from blocklist
description: Removes all movies in a collection from the blocklist
tags:
- blocklist
parameters:
- in: path
name: collectionId
description: Collection ID
required: true
example: '1424991'
schema:
type: string
responses:
'204':
description: Successfully removed collection from blocklist
'500':
description: Error removing collection from blocklist
/watchlist:
post:
summary: Add media to watchlist
@@ -4790,6 +4881,14 @@ paths:
example: '1'
schema:
type: string
- in: query
name: mediaType
required: true
schema:
type: string
enum:
- movie
- tv
responses:
'204':
description: Succesfully removed watchlist item
@@ -5964,6 +6063,23 @@ paths:
schema:
type: string
example: en
- in: query
name: mediaType
schema:
type: string
enum:
- all
- movie
- tv
default: all
- in: query
name: timeWindow
schema:
type: string
enum:
- day
- week
default: day
responses:
'200':
description: Results

View File

@@ -154,7 +154,9 @@ class AnimeListMapping {
{ label: 'Anime-List Sync' }
);
} catch (e) {
throw new Error(`Failed to load Anime-List mappings: ${e.message}`);
throw new Error(`Failed to load Anime-List mappings: ${e.message}`, {
cause: e,
});
}
};
@@ -173,7 +175,9 @@ class AnimeListMapping {
response.data.pipe(writer);
});
} catch (e) {
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
throw new Error(`Failed to download Anime-List mapping: ${e.message}`, {
cause: e,
});
}
};

View File

@@ -101,6 +101,7 @@ export interface JellyfinMediaSource {
export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
ProviderIds: {
Tmdb?: string;
TheMovieDb?: string;
Imdb?: string;
Tvdb?: string;
AniDB?: string;
@@ -137,11 +138,14 @@ class JellyfinAPI extends ExternalAPI {
? deviceId
: Buffer.from('BOT_seerr').toString('base64');
let authHeaderVal: string;
const version =
settings.main.mediaServerType === MediaServerType.EMBY
? '1.0.0'
: getAppVersion();
let authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${version}"`;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
authHeaderVal += `, Token="${authToken}"`;
}
super(
@@ -284,7 +288,7 @@ class JellyfinAPI extends ExternalAPI {
const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`);
return this.mapLibraries(mediaFolderResponse.Items);
} catch (mediaFoldersResponseError) {
} catch {
// fallback to user views to get libraries
// this only and maybe/depending on factors affects LDAP users
try {

View File

@@ -205,7 +205,7 @@ class PlexTvAPI extends ExternalAPI {
label: 'Plex.tv API',
errorMessage: e.message,
});
throw new Error('Invalid auth token');
throw new Error('Invalid auth token', { cause: e });
}
}
@@ -221,7 +221,7 @@ class PlexTvAPI extends ExternalAPI {
`Something went wrong while getting the account from plex.tv: ${e.message}`,
{ label: 'Plex.tv API' }
);
throw new Error('Invalid auth token');
throw new Error('Invalid auth token', { cause: e });
}
}

View File

@@ -48,7 +48,9 @@ class PushoverAPI extends ExternalAPI {
return mapSounds(data.sounds);
} catch (e) {
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`);
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`, {
cause: e,
});
}
}
}

View File

@@ -192,7 +192,8 @@ class IMDBRadarrProxy extends ExternalAPI {
};
} catch (e) {
throw new Error(
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`,
{ cause: e }
);
}
}

View File

@@ -167,7 +167,8 @@ class RottenTomatoes extends ExternalAPI {
};
} catch (e) {
throw new Error(
`[RT API] Failed to retrieve movie ratings: ${e.message}`
`[RT API] Failed to retrieve movie ratings: ${e.message}`,
{ cause: e }
);
}
}
@@ -205,7 +206,9 @@ class RottenTomatoes extends ExternalAPI {
year: Number(tvshow.releaseYear),
};
} catch (e) {
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`, {
cause: e,
});
}
}
}

View File

@@ -121,7 +121,8 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
`[${this.apiName}] Failed to retrieve system status: ${e.message}`,
{ cause: e }
);
}
};
@@ -137,7 +138,8 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve profiles: ${e.message}`
`[${this.apiName}] Failed to retrieve profiles: ${e.message}`,
{ cause: e }
);
}
};
@@ -153,7 +155,8 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve root folders: ${e.message}`
`[${this.apiName}] Failed to retrieve root folders: ${e.message}`,
{ cause: e }
);
}
};
@@ -172,7 +175,8 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return response.data.records;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
`[${this.apiName}] Failed to retrieve queue: ${e.message}`,
{ cause: e }
);
}
};
@@ -184,7 +188,8 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
`[${this.apiName}] Failed to retrieve tags: ${e.message}`,
{ cause: e }
);
}
};
@@ -197,7 +202,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return response.data;
} catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`, {
cause: e,
});
}
};
@@ -216,7 +223,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
return response.data;
} catch (e) {
throw new Error(`[${this.apiName}] Failed to rename tag: ${e.message}`);
throw new Error(`[${this.apiName}] Failed to rename tag: ${e.message}`, {
cause: e,
});
}
};
@@ -234,7 +243,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
...options,
});
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`, {
cause: e,
});
}
}
}

View File

@@ -74,7 +74,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`, {
cause: e,
});
}
};
@@ -84,7 +86,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`, {
cause: e,
});
}
};
@@ -107,7 +111,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
errorMessage: e.message,
tmdbId: id,
});
throw new Error('Movie not found');
throw new Error('Movie not found', { cause: e });
}
}
@@ -240,7 +244,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
response: e?.response?.data,
}
);
throw new Error('Failed to add movie to Radarr');
throw new Error('Failed to add movie to Radarr', { cause: e });
}
};
@@ -274,7 +278,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
});
logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) {
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`, {
cause: e,
});
}
};

View File

@@ -49,6 +49,7 @@ export interface SonarrSeries {
languageProfileId: number;
seasonFolder: boolean;
monitored: boolean;
monitorNewItems: 'all' | 'none';
useSceneNumbering: boolean;
runtime: number;
tvdbId: number;
@@ -98,6 +99,7 @@ export interface AddSeriesOptions {
tags?: number[];
seriesType: SonarrSeries['seriesType'];
monitored?: boolean;
monitorNewItems?: SonarrSeries['monitorNewItems'];
searchNow?: boolean;
}
@@ -121,7 +123,9 @@ class SonarrAPI extends ServarrBase<{
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`, {
cause: e,
});
}
}
@@ -131,7 +135,10 @@ class SonarrAPI extends ServarrBase<{
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
throw new Error(
`[Sonarr] Failed to retrieve series by ID: ${e.message}`,
{ cause: e }
);
}
}
@@ -154,7 +161,7 @@ class SonarrAPI extends ServarrBase<{
errorMessage: e.message,
title,
});
throw new Error('No series found');
throw new Error('No series found', { cause: e });
}
}
@@ -177,7 +184,7 @@ class SonarrAPI extends ServarrBase<{
errorMessage: e.message,
tvdbId: id,
});
throw new Error('Series not found');
throw new Error('Series not found', { cause: e });
}
}
@@ -269,6 +276,7 @@ class SonarrAPI extends ServarrBase<{
tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
monitorNewItems: options.monitorNewItems,
rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: {
@@ -300,7 +308,7 @@ class SonarrAPI extends ServarrBase<{
options,
response: e?.response?.data,
});
throw new Error('Failed to add series');
throw new Error('Failed to add series', { cause: e });
}
}
@@ -322,7 +330,7 @@ class SonarrAPI extends ServarrBase<{
}
);
throw new Error('Failed to get language profiles');
throw new Error('Failed to get language profiles', { cause: e });
}
}
@@ -358,7 +366,7 @@ class SonarrAPI extends ServarrBase<{
errorMessage: e.message,
seriesId,
});
throw new Error('Failed to get episodes');
throw new Error('Failed to get episodes', { cause: e });
}
}
@@ -374,7 +382,7 @@ class SonarrAPI extends ServarrBase<{
errorMessage: e.message,
episodeIds,
});
throw new Error('Failed to monitor episodes');
throw new Error('Failed to monitor episodes', { cause: e });
}
}
@@ -413,7 +421,9 @@ class SonarrAPI extends ServarrBase<{
});
logger.info(`[Sonarr] Removed series ${title}`);
} catch (e) {
throw new Error(`[Sonarr] Failed to remove series: ${e.message}`);
throw new Error(`[Sonarr] Failed to remove series: ${e.message}`, {
cause: e,
});
}
};

View File

@@ -140,7 +140,8 @@ class TautulliAPI {
errorMessage: e.message,
});
throw new Error(
`[Tautulli] Failed to fetch Tautulli server info: ${e.message}`
`[Tautulli] Failed to fetch Tautulli server info: ${e.message}`,
{ cause: e }
);
}
}
@@ -168,7 +169,8 @@ class TautulliAPI {
}
);
throw new Error(
`[Tautulli] Failed to fetch media watch stats: ${e.message}`
`[Tautulli] Failed to fetch media watch stats: ${e.message}`,
{ cause: e }
);
}
}
@@ -196,7 +198,8 @@ class TautulliAPI {
}
);
throw new Error(
`[Tautulli] Failed to fetch media watch users: ${e.message}`
`[Tautulli] Failed to fetch media watch users: ${e.message}`,
{ cause: e }
);
}
}
@@ -227,7 +230,8 @@ class TautulliAPI {
}
);
throw new Error(
`[Tautulli] Failed to fetch user watch stats: ${e.message}`
`[Tautulli] Failed to fetch user watch stats: ${e.message}`,
{ cause: e }
);
}
}
@@ -287,7 +291,8 @@ class TautulliAPI {
}
);
throw new Error(
`[Tautulli] Failed to fetch user watch history: ${e.message}`
`[Tautulli] Failed to fetch user watch history: ${e.message}`,
{ cause: e }
);
}
}

View File

@@ -162,7 +162,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
});
return data;
} catch (e) {
} catch {
return {
page: 1,
results: [],
@@ -191,7 +191,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
});
return data;
} catch (e) {
} catch {
return {
page: 1,
results: [],
@@ -220,7 +220,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
});
return data;
} catch (e) {
} catch {
return {
page: 1,
results: [],
@@ -244,7 +244,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`, {
cause: e,
});
}
};
@@ -266,7 +268,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
`[TMDB] Failed to fetch person combined credits: ${e.message}`,
{ cause: e }
);
}
};
@@ -286,15 +289,55 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
include_video_language: language + ', en',
include_video_language: language,
},
},
43200
);
if (
(!language || !language.startsWith('en')) &&
!data.videos?.results?.some((video) => video.type === 'Trailer')
) {
try {
const fallback = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response: 'videos',
include_video_language: 'en',
},
},
43200
);
const localizedVideos = data.videos?.results ?? [];
const localizedVideoKeys = new Set(
localizedVideos.map((video) => video.key)
);
const englishFallbackTrailers =
fallback.videos?.results?.filter(
(video) =>
video.type === 'Trailer' && !localizedVideoKeys.has(video.key)
) ?? [];
if (englishFallbackTrailers.length > 0) {
data.videos = {
...(data.videos ?? { results: [] }),
results: [...localizedVideos, ...englishFallbackTrailers],
};
}
} catch {
// Ignore trailer fallback failures; return the original data.
}
}
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`, {
cause: e,
});
}
};
@@ -313,15 +356,55 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
include_video_language: language + ', en',
include_video_language: language,
},
},
43200
);
if (
(!language || !language.startsWith('en')) &&
!data.videos?.results?.some((video) => video.type === 'Trailer')
) {
try {
const fallback = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
params: {
language,
append_to_response: 'videos',
include_video_language: 'en',
},
},
43200
);
const localizedVideos = data.videos?.results ?? [];
const localizedVideoKeys = new Set(
localizedVideos.map((video) => video.key)
);
const englishFallbackTrailers =
fallback.videos?.results?.filter(
(video) =>
video.type === 'Trailer' && !localizedVideoKeys.has(video.key)
) ?? [];
if (englishFallbackTrailers.length > 0) {
data.videos = {
...(data.videos ?? { results: [] }),
results: [...localizedVideos, ...englishFallbackTrailers],
};
}
} catch {
// Ignore trailer fallback failures; return the original data.
}
}
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`, {
cause: e,
});
}
};
@@ -354,7 +437,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`, {
cause: e,
});
}
};
@@ -380,7 +465,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`, {
cause: e,
});
}
}
@@ -406,7 +493,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`, {
cause: e,
});
}
}
@@ -432,7 +521,10 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
throw new Error(
`[TMDB] Failed to fetch movies by keyword: ${e.message}`,
{ cause: e }
);
}
}
@@ -459,7 +551,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV recommendations: ${e.message}`
`[TMDB] Failed to fetch TV recommendations: ${e.message}`,
{ cause: e }
);
}
}
@@ -483,7 +576,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`, {
cause: e,
});
}
}
@@ -569,7 +664,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`, {
cause: e,
});
}
};
@@ -655,7 +752,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`, {
cause: e,
});
}
};
@@ -681,7 +780,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`, {
cause: e,
});
}
};
@@ -708,16 +809,20 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`, {
cause: e,
});
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
language = this.locale,
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>(
@@ -725,22 +830,27 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`, {
cause: e,
});
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
language = this.locale,
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>(
@@ -748,13 +858,16 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`, {
cause: e,
});
}
};
@@ -786,7 +899,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`, {
cause: e,
});
}
}
@@ -824,7 +939,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
} catch (e) {
throw new Error(
`[TMDB] Failed to find media using external IMDb ID: ${e.message}`
`[TMDB] Failed to find media using external IMDb ID: ${e.message}`,
{ cause: e }
);
}
}
@@ -854,7 +970,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
throw new Error(`No show returned from API for ID ${tvdbId}`);
} catch (e) {
throw new Error(
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`,
{ cause: e }
);
}
}
@@ -878,7 +995,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`, {
cause: e,
});
}
}
@@ -894,7 +1013,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return regions;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`, {
cause: e,
});
}
}
@@ -910,7 +1031,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return languages;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`, {
cause: e,
});
}
}
@@ -922,7 +1045,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`, {
cause: e,
});
}
}
@@ -932,7 +1057,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`, {
cause: e,
});
}
}
@@ -983,7 +1110,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return movieGenres;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`, {
cause: e,
});
}
}
@@ -1034,7 +1163,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return tvGenres;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`, {
cause: e,
});
}
}
@@ -1049,7 +1180,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`);
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`, {
cause: e,
});
}
};
@@ -1063,7 +1196,10 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV certifications: ${e.message}`);
throw new Error(
`[TMDB] Failed to fetch TV certifications: ${e.message}`,
{ cause: e }
);
}
};
@@ -1084,7 +1220,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
if (e.response?.status === 404) {
return null;
}
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`, {
cause: e,
});
}
}
@@ -1109,7 +1247,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`, {
cause: e,
});
}
}
@@ -1134,7 +1274,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
throw new Error(`[TMDB] Failed to search companies: ${e.message}`, {
cause: e,
});
}
}
@@ -1157,7 +1299,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch available watch regions: ${e.message}`
`[TMDB] Failed to fetch available watch regions: ${e.message}`,
{ cause: e }
);
}
}
@@ -1184,7 +1327,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
`[TMDB] Failed to fetch movie watch providers: ${e.message}`,
{ cause: e }
);
}
}
@@ -1211,7 +1355,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
`[TMDB] Failed to fetch TV watch providers: ${e.message}`,
{ cause: e }
);
}
}

View File

@@ -156,7 +156,7 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
}
return tmdbTvShow;
} catch (error) {
} catch {
return tmdbTvShow;
}
} catch (error) {

View File

@@ -166,6 +166,7 @@ const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
el: 'ell', // Greek
en: 'eng', // English
es: 'spa', // Spanish
et: 'est', // Estonian
fi: 'fin', // Finnish
fr: 'fra', // French
he: 'heb', // Hebrew
@@ -175,6 +176,7 @@ const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
it: 'ita', // Italian
ja: 'jpn', // Japanese
ko: 'kor', // Korean
lb: 'ltz', // Luxembourgish
lt: 'lit', // Lithuanian
nl: 'nld', // Dutch
pl: 'pol', // Polish
@@ -185,6 +187,7 @@ const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
sv: 'swe', // Swedish
tr: 'tur', // Turkish
uk: 'ukr', // Ukrainian
vi: 'vie', // Vietnamese
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian

View File

@@ -38,6 +38,17 @@ function buildSslConfig(): TlsOptions | undefined {
};
}
const testConfig: DataSourceOptions = {
type: 'sqlite',
database: ':memory:',
synchronize: true,
dropSchema: true,
logging: boolFromEnv('DB_LOG_QUERIES'),
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/sqlite/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
const devConfig: DataSourceOptions = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
@@ -105,7 +116,9 @@ const postgresProdConfig: DataSourceOptions = {
export const isPgsql = process.env.DB_TYPE === 'postgres';
function getDataSource(): DataSourceOptions {
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === 'test') {
return testConfig;
} else if (process.env.NODE_ENV === 'production') {
return isPgsql ? postgresProdConfig : prodConfig;
} else {
return isPgsql ? postgresDevConfig : devConfig;

View File

@@ -18,7 +18,7 @@ import {
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity()
@Unique(['tmdbId'])
@Unique(['tmdbId', 'mediaType'])
export class Blocklist implements BlocklistItem {
@PrimaryGeneratedColumn()
public id: number;
@@ -77,6 +77,7 @@ export class Blocklist implements BlocklistItem {
let media = await mediaRepository.findOne({
where: {
tmdbId: blocklistRequest.tmdbId,
mediaType: blocklistRequest.mediaType,
},
});

View File

@@ -2,8 +2,13 @@ import type { DiscoverSliderType } from '@server/constants/discover';
import { defaultSliders } from '@server/constants/discover';
import { getRepository } from '@server/datasource';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class DiscoverSlider {
@@ -53,11 +58,7 @@ class DiscoverSlider {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
constructor(init?: Partial<DiscoverSlider>) {

View File

@@ -1,6 +1,6 @@
import type { IssueType } from '@server/constants/issue';
import { IssueStatus } from '@server/constants/issue';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
AfterLoad,
Column,
@@ -9,6 +9,7 @@ import {
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import IssueComment from './IssueComment';
import Media from './Media';
@@ -63,11 +64,7 @@ class Issue {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
@AfterLoad()

View File

@@ -1,10 +1,11 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { User } from './User';
@@ -33,11 +34,7 @@ class IssueComment {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
constructor(init?: Partial<IssueComment>) {

View File

@@ -10,7 +10,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
@@ -20,6 +20,7 @@ import {
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
@@ -30,22 +31,17 @@ import Season from './Season';
class Media {
public static async getRelatedMedia(
user: User | undefined,
tmdbIds: number | number[]
items: { tmdbId: number; mediaType: string }[]
): Promise<Media[]> {
const mediaRepository = getRepository(Media);
try {
let finalIds: number[];
if (!Array.isArray(tmdbIds)) {
finalIds = [tmdbIds];
} else {
finalIds = tmdbIds;
}
if (finalIds.length === 0) {
if (items.length === 0) {
return [];
}
const finalIds = [...new Set(items.map((i) => i.tmdbId))];
const media = await mediaRepository
.createQueryBuilder('media')
.leftJoinAndSelect(
@@ -57,7 +53,9 @@ class Media {
.where(' media.tmdbId in (:...finalIds)', { finalIds })
.getMany();
return media;
return media.filter((m) =>
items.some((i) => i.tmdbId === m.tmdbId && i.mediaType === m.mediaType)
);
} catch (e) {
logger.error(e.message);
return [];
@@ -132,11 +130,7 @@ class Media {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
/**
@@ -237,19 +231,19 @@ class Media {
if (tautulliUrl) {
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
}
}
if (this.ratingKey4k) {
this.mediaUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
if (this.ratingKey4k) {
this.mediaUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
}
} else {

View File

@@ -13,7 +13,7 @@ import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import { truncate } from 'lodash';
import {
AfterInsert,
@@ -26,6 +26,7 @@ import {
OneToMany,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import Media from './Media';
import SeasonRequest from './SeasonRequest';
@@ -543,11 +544,7 @@ export class MediaRequest {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
@Column({ type: 'varchar' })

View File

@@ -1,5 +1,10 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class OverrideRule {
@@ -36,11 +41,7 @@ class OverrideRule {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
constructor(init?: Partial<OverrideRule>) {

View File

@@ -1,11 +1,12 @@
import { MediaStatus } from '@server/constants/media';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Media from './Media';
@@ -32,11 +33,7 @@ class Season {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
constructor(init?: Partial<Season>) {

View File

@@ -1,11 +1,12 @@
import { MediaRequestStatus } from '@server/constants/media';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
@@ -29,11 +30,7 @@ class SeasonRequest {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
constructor(init?: Partial<SeasonRequest>) {

View File

@@ -8,7 +8,7 @@ import type { PermissionCheckOptions } from '@server/lib/permissions';
import { Permission, hasPermission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import { AfterDate } from '@server/utils/dateHelpers';
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
@@ -23,6 +23,7 @@ import {
OneToOne,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
@@ -79,7 +80,7 @@ export class User {
@Column({ nullable: true, select: false })
public resetPasswordGuid?: string;
@Column({ type: 'date', nullable: true })
@DbAwareColumn({ type: 'datetime', nullable: true })
public recoveryLinkExpirationDate?: Date | null;
@Column({ type: 'integer', default: UserType.PLEX })
@@ -149,11 +150,7 @@ export class User {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
public warnings: string[] = [];
@@ -197,7 +194,7 @@ export class User {
public async generatePassword(): Promise<void> {
const password = generatePassword.randomPassword({ length: 16 });
this.setPassword(password);
await this.setPassword(password);
const { applicationTitle, applicationUrl } = getSettings().main;
try {
@@ -296,7 +293,7 @@ export class User {
requestedBy: {
id: this.id,
},
createdAt: AfterDate(movieDate),
...(movieQuotaDays ? { createdAt: AfterDate(movieDate) } : {}),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
},
@@ -314,24 +311,28 @@ export class User {
tvDate.setDate(tvDate.getDate() - tvQuotaDays);
}
const tvQuotaStartDate = tvDate.toJSON();
const tvQuotaUsedQuery = requestRepository
.createQueryBuilder('request')
.leftJoin('request.requestedBy', 'requestedBy')
.where('request.type = :requestType', {
requestType: MediaType.TV,
})
.andWhere('requestedBy.id = :userId', {
userId: this.id,
})
.andWhere('request.status != :declinedStatus', {
declinedStatus: MediaRequestStatus.DECLINED,
});
if (tvQuotaDays) {
tvQuotaUsedQuery.andWhere('request.createdAt > :date', {
date: tvQuotaStartDate,
});
}
const tvQuotaUsed = tvQuotaLimit
? (
await requestRepository
.createQueryBuilder('request')
.leftJoin('request.seasons', 'seasons')
.leftJoin('request.requestedBy', 'requestedBy')
.where('request.type = :requestType', {
requestType: MediaType.TV,
})
.andWhere('requestedBy.id = :userId', {
userId: this.id,
})
.andWhere('request.createdAt > :date', {
date: tvQuotaStartDate,
})
.andWhere('request.status != :declinedStatus', {
declinedStatus: MediaRequestStatus.DECLINED,
})
await tvQuotaUsedQuery
.addSelect((subQuery) => {
return subQuery
.select('COUNT(season.id)', 'seasonCount')
@@ -351,10 +352,9 @@ export class User {
remaining: movieQuotaLimit
? Math.max(0, movieQuotaLimit - movieQuotaUsed)
: undefined,
restricted:
restricted: !!(
movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0
? true
: false,
),
},
tv: {
days: tvQuotaDays,
@@ -363,8 +363,7 @@ export class User {
remaining: tvQuotaLimit
? Math.max(0, tvQuotaLimit - tvQuotaUsed)
: undefined,
restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
restricted: !!(tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0),
},
};
}

View File

@@ -5,7 +5,7 @@ import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
@@ -13,6 +13,7 @@ import {
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@@ -25,7 +26,7 @@ export class NotFoundError extends Error {
}
@Entity()
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
@Unique('UNIQUE_USER_DB', ['tmdbId', 'mediaType', 'requestedBy'])
export class Watchlist implements WatchlistItem {
@PrimaryGeneratedColumn()
id: number;
@@ -60,11 +61,7 @@ export class Watchlist implements WatchlistItem {
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn({ type: resolveDbType('datetime') })
public updatedAt: Date;
constructor(init?: Partial<Watchlist>) {
@@ -142,11 +139,13 @@ export class Watchlist implements WatchlistItem {
public static async deleteWatchlist(
tmdbId: Watchlist['tmdbId'],
mediaType: MediaType,
user: User
): Promise<Watchlist | null> {
const watchlistRepository = getRepository(this);
const watchlist = await watchlistRepository.findOneBy({
tmdbId,
mediaType,
requestedBy: { id: user.id },
});
if (!watchlist) {

View File

@@ -203,7 +203,7 @@ app
server.use(
'/api',
session({
secret: settings.clientId,
secret: settings.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {

View File

@@ -13,7 +13,7 @@ export interface PaginatedResponse {
* Get the keys of an object that are not functions
*/
type NonFunctionPropertyNames<T> = {
// eslint-disable-next-line @typescript-eslint/ban-types
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

View File

@@ -48,6 +48,7 @@ export interface PublicSettingsResponse {
emailEnabled: boolean;
newPlexLogin: boolean;
youtubeUrl: string;
plexClientIdentifier: string;
}
export interface CacheItem {

View File

@@ -14,7 +14,7 @@ import type {
} from '@server/lib/scanners/baseScanner';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
import { createTmdbWithBlocklistSettings } from '@server/routes/discover';
import type { EntityManager } from 'typeorm';
const TMDB_API_DELAY_MS = 250;
@@ -65,7 +65,7 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
}
private async createBlocklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();
const tmdb = createTmdbWithBlocklistSettings();
const settings = getSettings();
const blocklistedTags = settings.main.blocklistedTags;
@@ -173,7 +173,7 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
for (const entry of response.results) {
const blocklistEntry = await blocklistRepository.findOne({
where: { tmdbId: entry.id },
where: { tmdbId: entry.id, mediaType },
});
if (blocklistEntry) {
@@ -209,7 +209,11 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
const mediaRepository = em.getRepository(Media);
const mediaToRemove = await mediaRepository
.createQueryBuilder('media')
.innerJoinAndSelect(Blocklist, 'blist', 'blist.tmdbId = media.tmdbId')
.innerJoinAndSelect(
Blocklist,
'blist',
'blist.tmdbId = media.tmdbId AND blist.mediaType = media.mediaType'
)
.where(`blist.blocklistedTags IS NOT NULL`)
.getMany();

View File

@@ -97,7 +97,12 @@ export const startJobs = (): void => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
watchlistSync.syncWatchlist().catch((e) => {
logger.error('Failed to sync watchlists', {
label: 'Plex Watchlist Sync',
errorMessage: e.message,
});
});
}),
});
} else if (

View File

@@ -80,29 +80,24 @@ class PGPEncryptor extends Transform {
let previousHeader: string[] = [];
for (let i = 0; i < linesInHeader.length; i++) {
const line = linesInHeader[i];
/**
* If it is a multi-line header (current line starts with whitespace)
* or it's the first line in the iteration
* add the current line with previous header and move on
*/
if (/^\s/.test(line) || i === 0) {
previousHeader.push(line);
continue;
} else {
if (
/^(content-type|content-transfer-encoding):/i.test(
previousHeader[0]
)
) {
contentHeaders.push(previousHeader);
} else {
emailHeaders.push(previousHeader);
}
previousHeader = [line];
}
}
/**
* This is done to prevent the last header
* from being missed
*/
if (i === linesInHeader.length - 1) {
previousHeader.push(line);
}
/**
* We need to seperate the actual content headers
* so that we can add it as a header for the encrypted content
* So that the content will be displayed properly after decryption
*/
if (previousHeader.length > 0) {
if (
/^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
) {
@@ -110,7 +105,6 @@ class PGPEncryptor extends Transform {
} else {
emailHeaders.push(previousHeader);
}
previousHeader = [line];
}
// Generate a new boundary for the email content

View File

@@ -55,16 +55,12 @@ class ImageProxy {
}
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
return;
}
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
@@ -254,7 +250,7 @@ class ImageProxy {
imageBuffer: buffer,
};
}
} catch (e) {
} catch {
// No files. Treat as empty cache.
}

View File

@@ -214,16 +214,13 @@ class DiscordAgent
: undefined;
return {
title: payload.subject,
title: payload.event
? `${payload.event}: ${payload.subject}`
: payload.subject,
url,
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: payload.event
? {
name: payload.event,
}
: undefined,
fields,
thumbnail: embedPoster
? {

View File

@@ -21,13 +21,17 @@ class NtfyAgent
return settings.notifications.agents.ntfy;
}
private escapeMarkdown(text: string): string {
return text.replace(/([\\`*_{}[\]()#+\-.!|>~<])/g, '\\$1');
}
private buildPayload(type: Notification, payload: NotificationPayload) {
const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.ntfy;
const topic = this.getSettings().options.topic;
const priority = 3;
const priority = this.getSettings().options.priority ?? 3;
const title = payload.event
? `${payload.event} - ${payload.subject}`
@@ -35,7 +39,10 @@ class NtfyAgent
let message = payload.message ?? '';
if (payload.request) {
message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
if (message) {
message = `**Description:**\n${message}`;
}
message += `${message ? '\n\n' : ''}**Requested By:** ${this.escapeMarkdown(payload.request.requestedBy.displayName)}`;
let status = '';
switch (type) {
@@ -58,14 +65,21 @@ class NtfyAgent
}
if (status) {
message += `\nRequest Status: ${status}`;
message += `\n**Request Status:** ${status}`;
}
} else if (payload.comment) {
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
if (message) {
message = `**Description:**\n${message}\n\n`;
}
message += `**Comment:**\n${payload.comment.message}`;
message += `\n\n**Comment from:** ${this.escapeMarkdown(payload.comment.user.displayName)}`;
} else if (payload.issue) {
message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
message += `\nIssue Status: ${
if (message) {
message = `**Description:**\n${message}`;
}
message += `${message ? '\n\n' : ''}**Reported By:** ${this.escapeMarkdown(payload.issue.createdBy.displayName)}`;
message += `\n**Issue Type:** ${IssueTypeName[payload.issue.issueType]}`;
message += `\n**Issue Status:** ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
}
@@ -86,6 +100,7 @@ class NtfyAgent
priority,
title,
message,
markdown: true,
attach,
click,
};

View File

@@ -25,14 +25,18 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_avatar: 'notifyUser.avatar',
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_imdbid: 'media.imdbId',
media_tmdbid: 'media.tmdbId',
media_tvdbid: 'media.tvdbId',
media_type: 'media.mediaType',
media_jellyfinMediaId: (payload) =>
payload.media?.jellyfinMediaId ?? payload.media?.jellyfinMediaId4k ?? '',
media_status: (payload) =>
payload.media ? MediaStatus[payload.media.status] : '',
media_status4k: (payload) =>
payload.media ? MediaStatus[payload.media.status4k] : '',
request_id: 'request.id',
requestedBy_jellyfinUserId: 'request.requestedBy.jellyfinUserId',
requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email',
requestedBy_avatar: 'request.requestedBy.avatar',
@@ -196,16 +200,36 @@ class WebhookAgent
}
try {
const headers: Record<string, string> = {};
if (settings.options.authHeader) {
headers.Authorization = settings.options.authHeader;
}
if (
settings.options.customHeaders &&
settings.options.customHeaders.length > 0
) {
settings.options.customHeaders.forEach((header) => {
const key = header.key?.trim();
const value = header.value?.trim();
if (key && value) {
// Don't override Authorization header if it's already set via authHeader
if (
key.toLowerCase() !== 'authorization' ||
!settings.options.authHeader
) {
headers[key] = value;
}
}
});
}
await axios.post(
webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
Object.keys(headers).length > 0 ? { headers } : undefined
);
return true;

View File

@@ -23,7 +23,7 @@ export const hasNotificationType = (
types: Notification | Notification[],
value: number
): boolean => {
let total = 0;
let total: number;
// If we are not checking any notifications, bail out and return true
if (types === 0) {

View File

@@ -36,7 +36,7 @@ const checkOverseerrMerge = async (): Promise<boolean> => {
// We have to replace Jellyseerr migrations not working with Overseerr with a custom one
try {
// Filter out the Jellyseerr migrations and replace them with the Seerr migration
// eslint-disable-next-line @typescript-eslint/ban-types
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const newMigrations: MixedList<string | Function> = migrations
?.filter(
(migration) =>
@@ -89,6 +89,27 @@ const checkOverseerrMerge = async (): Promise<boolean> => {
await dbConnection.query('PRAGMA foreign_keys=ON');
}
// Fix corrupted quota values carried over from Overseerr
try {
await dbConnection.query(
`UPDATE user SET movieQuotaLimit = NULL WHERE typeof(movieQuotaLimit) = 'text'`
);
await dbConnection.query(
`UPDATE user SET movieQuotaDays = NULL WHERE typeof(movieQuotaDays) = 'text'`
);
await dbConnection.query(
`UPDATE user SET tvQuotaLimit = NULL WHERE typeof(tvQuotaLimit) = 'text'`
);
await dbConnection.query(
`UPDATE user SET tvQuotaDays = NULL WHERE typeof(tvQuotaDays) = 'text'`
);
} catch (error) {
logger.error('Failed to clean up corrupted quota values', {
label: 'Seerr Migration',
error: error.message,
});
}
// MediaStatus.Blacklisted was added before MediaStatus.Deleted in Jellyseerr
try {
const mediaRepository = getRepository(Media);

View File

@@ -60,7 +60,9 @@ class JellyfinScanner
}
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
let tmdbId = Number(
metadata.ProviderIds.Tmdb || metadata.ProviderIds.TheMovieDb || null
);
let imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else
@@ -227,10 +229,12 @@ class JellyfinScanner
return;
}
if (metadata.ProviderIds.Tmdb) {
if (metadata.ProviderIds.Tmdb || metadata.ProviderIds.TheMovieDb) {
try {
tvShow = await this.getTvShow({
tmdbId: Number(metadata.ProviderIds.Tmdb),
tmdbId: Number(
metadata.ProviderIds.Tmdb || metadata.ProviderIds.TheMovieDb
),
});
} catch {
this.log('Unable to find TMDb ID for this title.', 'debug', {

View File

@@ -367,18 +367,16 @@ class PlexScanner
}
}
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
mediaAddedAt: new Date(metadata.addedAt * 1000),
ratingKey: ratingKey,
title: metadata.title,
}
);
}
await this.processShow(
mediaIds.tmdbId,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
mediaAddedAt: new Date(metadata.addedAt * 1000),
ratingKey: ratingKey,
title: metadata.title,
}
);
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {

View File

@@ -1,12 +1,18 @@
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto';
import { randomBytes, randomUUID } from 'crypto';
import fs from 'fs/promises';
import { merge } from 'lodash';
import { mergeWith } from 'lodash';
import path from 'path';
import webpush from 'web-push';
// Prevents stale array entries when incoming data has fewer elements
const mergeSettings = <T>(current: T, incoming: Partial<T>): T =>
mergeWith({}, current, incoming, (_objValue, srcValue) =>
Array.isArray(srcValue) ? srcValue : undefined
) as T;
export interface Library {
id: string;
name: string;
@@ -93,6 +99,7 @@ export interface SonarrSettings extends DVRSettings {
activeLanguageProfileId?: number;
animeTags?: number[];
enableSeasonFolders: boolean;
monitorNewItems: 'all' | 'none';
}
interface Quota {
@@ -139,6 +146,8 @@ export interface MainSettings {
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
blocklistRegion: string;
blocklistLanguage: string;
blocklistedTags: string;
blocklistedTagsLimit: number;
mediaServerType: number;
@@ -204,6 +213,7 @@ interface FullPublicSettings extends PublicSettings {
userEmailRequired: boolean;
newPlexLogin: boolean;
youtubeUrl: string;
plexClientIdentifier: string;
}
export interface NotificationAgentConfig {
@@ -276,6 +286,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
webhookUrl: string;
jsonPayload: string;
authHeader?: string;
customHeaders?: { key: string; value: string }[];
supportVariables?: boolean;
};
}
@@ -297,6 +308,7 @@ export interface NotificationAgentNtfy extends NotificationAgentConfig {
password?: string;
authMethodToken?: boolean;
token?: string;
priority?: number;
};
}
@@ -351,6 +363,7 @@ export type JobId =
export interface AllSettings {
clientId: string;
sessionSecret?: string;
vapidPublic: string;
vapidPrivate: string;
main: MainSettings;
@@ -373,10 +386,12 @@ const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
class Settings {
private data: AllSettings;
private saveLock: Promise<void> = Promise.resolve();
constructor(initialSettings?: AllSettings) {
this.data = {
clientId: randomUUID(),
sessionSecret: '',
vapidPrivate: '',
vapidPublic: '',
main: {
@@ -397,6 +412,8 @@ class Settings {
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
blocklistRegion: '',
blocklistLanguage: '',
blocklistedTags: '',
blocklistedTagsLimit: 50,
mediaServerType: MediaServerType.NOT_CONFIGURED,
@@ -530,6 +547,7 @@ class Settings {
options: {
url: '',
topic: '',
priority: 3,
},
},
},
@@ -599,7 +617,7 @@ class Settings {
migrations: [],
};
if (initialSettings) {
this.data = merge(this.data, initialSettings);
this.data = mergeSettings(this.data, initialSettings);
}
}
@@ -608,7 +626,7 @@ class Settings {
}
set main(data: MainSettings) {
this.data.main = data;
this.data.main = mergeSettings(this.data.main, data);
}
get plex(): PlexSettings {
@@ -616,7 +634,7 @@ class Settings {
}
set plex(data: PlexSettings) {
this.data.plex = data;
this.data.plex = mergeSettings(this.data.plex, data);
}
get jellyfin(): JellyfinSettings {
@@ -624,7 +642,7 @@ class Settings {
}
set jellyfin(data: JellyfinSettings) {
this.data.jellyfin = data;
this.data.jellyfin = mergeSettings(this.data.jellyfin, data);
}
get tautulli(): TautulliSettings {
@@ -632,7 +650,7 @@ class Settings {
}
set tautulli(data: TautulliSettings) {
this.data.tautulli = data;
this.data.tautulli = mergeSettings(this.data.tautulli, data);
}
get metadataSettings(): MetadataSettings {
@@ -640,7 +658,10 @@ class Settings {
}
set metadataSettings(data: MetadataSettings) {
this.data.metadataSettings = data;
this.data.metadataSettings = mergeSettings(
this.data.metadataSettings,
data
);
}
get radarr(): RadarrSettings[] {
@@ -664,7 +685,7 @@ class Settings {
}
set public(data: PublicSettings) {
this.data.public = data;
this.data.public = mergeSettings(this.data.public, data);
}
get fullPublicSettings(): FullPublicSettings {
@@ -699,6 +720,7 @@ class Settings {
this.data.notifications.agents.email.options.userEmailRequired,
newPlexLogin: this.data.main.newPlexLogin,
youtubeUrl: this.data.main.youtubeUrl,
plexClientIdentifier: this.data.clientId,
};
}
@@ -707,7 +729,7 @@ class Settings {
}
set notifications(data: NotificationSettings) {
this.data.notifications = data;
this.data.notifications = mergeSettings(this.data.notifications, data);
}
get jobs(): Record<JobId, JobSettings> {
@@ -715,7 +737,7 @@ class Settings {
}
set jobs(data: Record<JobId, JobSettings>) {
this.data.jobs = data;
this.data.jobs = mergeSettings(this.data.jobs, data);
}
get network(): NetworkSettings {
@@ -723,7 +745,7 @@ class Settings {
}
set network(data: NetworkSettings) {
this.data.network = data;
this.data.network = mergeSettings(this.data.network, data);
}
get migrations(): string[] {
@@ -738,6 +760,10 @@ class Settings {
return this.data.clientId;
}
get sessionSecret(): string {
return this.data.sessionSecret!;
}
get vapidPublic(): string {
return this.data.vapidPublic;
}
@@ -785,16 +811,22 @@ class Settings {
await this.save();
}
let change = false;
if (data && !raw) {
const parsedJson = JSON.parse(data);
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, migratedData);
const merged = mergeSettings(this.data, migratedData);
if (JSON.stringify(merged) !== JSON.stringify(migratedData)) {
change = true;
}
this.data = merged;
} else if (data) {
this.data = JSON.parse(data);
}
// generate keys and ids if it's missing
let change = false;
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
change = true;
@@ -807,6 +839,10 @@ class Settings {
this.data.clientId = randomUUID();
change = true;
}
if (!this.data.sessionSecret) {
this.data.sessionSecret = randomBytes(32).toString('hex');
change = true;
}
if (!this.data.vapidPublic || !this.data.vapidPrivate) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
@@ -821,9 +857,17 @@ class Settings {
}
public async save(): Promise<void> {
const tmp = SETTINGS_PATH + '.tmp';
await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' '));
await fs.rename(tmp, SETTINGS_PATH);
const savePromise = this.saveLock.then(async () => {
const tmp = SETTINGS_PATH + '.tmp';
await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' '));
await fs.rename(tmp, SETTINGS_PATH);
});
this.saveLock = savePromise.catch(() => {
// Keep the chain alive so future saves aren't blocked by past failures
});
return savePromise;
}
}

View File

@@ -67,19 +67,27 @@ class WatchlistSync {
const mediaItems = await Media.getRelatedMedia(
user,
response.items.map((i) => i.tmdbId)
response.items.map((i) => ({
tmdbId: i.tmdbId,
mediaType: i.type === 'show' ? MediaType.TV : MediaType.MOVIE,
}))
);
const watchlistTmdbIds = response.items.map((i) => i.tmdbId);
const requestRepository = getRepository(MediaRequest);
const existingAutoRequests = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('request.requestedBy = :userId', { userId: user.id })
.andWhere('request.isAutoRequest = true')
.andWhere('media.tmdbId IN (:...tmdbIds)', { tmdbIds: watchlistTmdbIds })
.getMany();
const existingAutoRequests: MediaRequest[] =
watchlistTmdbIds.length > 0
? await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('request.requestedBy = :userId', { userId: user.id })
.andWhere('request.isAutoRequest = true')
.andWhere('media.tmdbId IN (:...tmdbIds)', {
tmdbIds: watchlistTmdbIds,
})
.getMany()
: [];
const autoRequestedTmdbIds = new Set(
existingAutoRequests
@@ -87,28 +95,26 @@ class WatchlistSync {
.map((r) => `${r.media.mediaType}:${r.media.tmdbId}`)
);
const unavailableItems = response.items.filter(
(i) =>
!autoRequestedTmdbIds.has(
`${i.type === 'show' ? MediaType.TV : MediaType.MOVIE}:${i.tmdbId}`
) &&
const unavailableItems = response.items.filter((i) => {
const itemMediaType = i.type === 'show' ? MediaType.TV : MediaType.MOVIE;
return (
!autoRequestedTmdbIds.has(`${itemMediaType}:${i.tmdbId}`) &&
!mediaItems.find(
(m) =>
m.tmdbId === i.tmdbId &&
m.mediaType === itemMediaType &&
(m.status === MediaStatus.BLOCKLISTED ||
(m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
(itemMediaType === MediaType.MOVIE &&
m.status !== MediaStatus.UNKNOWN) ||
(itemMediaType === MediaType.TV &&
m.status === MediaStatus.AVAILABLE))
)
);
);
});
for (const mediaItem of unavailableItems) {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
@@ -144,6 +150,12 @@ class WatchlistSync {
user,
{ isAutoRequest: true }
);
logger.info("Created media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
} catch (e) {
if (!(e instanceof Error)) {
continue;

View File

@@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RecoveryLinkExpirationDateTime1771337333450 implements MigrationInterface {
name = 'RecoveryLinkExpirationDateTime1771337333450';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE TIMESTAMP WITH TIME ZONE`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE date USING ("recoveryLinkExpirationDate"::date)`
);
}
}

View File

@@ -0,0 +1,20 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class FixBlocklistIdDefault1772000000000 implements MigrationInterface {
name = 'FixBlocklistIdDefault1772000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('public."blocklist_id_seq"'::regclass)`
);
await queryRunner.query(
`SELECT setval('public."blocklist_id_seq"', COALESCE((SELECT MAX("id") FROM "blocklist"), 0) + 1, false)`
);
}
public async down(): Promise<void> {
// Intentionally left empty: dropping the DEFAULT on blocklist.id would
// reintroduce the original bug and break blocklist inserts.
}
}

View File

@@ -0,0 +1,51 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaTypeToUniqueConstraints1772048000333 implements MigrationInterface {
name = 'AddMediaTypeToUniqueConstraints1772048000333';
public async up(queryRunner: QueryRunner): Promise<void> {
// Manually added: TypeORM migration:generate does not detect changes to named unique constraints.
await queryRunner.query(
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "mediaType", "requestedById")`
);
// Auto-generated by TypeORM
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"`
);
await queryRunner.query(
`ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')`
);
await queryRunner.query(
`ALTER TABLE "blocklist" DROP CONSTRAINT "UQ_6bbafa28411e6046421991ea21c"`
);
await queryRunner.query(
`ALTER TABLE "blocklist" ADD CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6" UNIQUE ("tmdbId", "mediaType")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Manually added: TypeORM migration:generate does not detect changes to named unique constraints.
await queryRunner.query(
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")`
);
// Auto-generated by TypeORM
await queryRunner.query(
`ALTER TABLE "blocklist" DROP CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6"`
);
await queryRunner.query(
`ALTER TABLE "blocklist" ADD CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId")`
);
await queryRunner.query(
`ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT`
);
await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`);
}
}

View File

@@ -0,0 +1,27 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RecoveryLinkExpirationDateTime1771337037917 implements MigrationInterface {
name = 'RecoveryLinkExpirationDateTime1771337037917';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

View File

@@ -0,0 +1,225 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaTypeToUniqueConstraints1772047972752 implements MigrationInterface {
name = 'AddMediaTypeToUniqueConstraints1772047972752';
public async up(queryRunner: QueryRunner): Promise<void> {
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_356721a49f145aa439c16e6b99"`);
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
await queryRunner.query(
`CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"`
);
await queryRunner.query(`DROP TABLE "blocklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
);
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_356721a49f145aa439c16e6b99"`);
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
await queryRunner.query(
`CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"`
);
await queryRunner.query(`DROP TABLE "blocklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
await queryRunner.query(
`CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6" UNIQUE ("tmdbId", "mediaType"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"`
);
await queryRunner.query(`DROP TABLE "blocklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
);
// Manually added as TypeORM migration:generate does not detect changes to named unique constraints.
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
await queryRunner.query(`DROP INDEX "IDX_ae34e6b153a90672eb9dc4857d"`);
await queryRunner.query(`DROP INDEX "IDX_6641da8d831b93dfcb429f8b8b"`);
await queryRunner.query(
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "mediaType", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
);
await queryRunner.query(`DROP TABLE "watchlist"`);
await queryRunner.query(
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId")`
);
await queryRunner.query(
`CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById")`
);
await queryRunner.query(
`CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Manually added as TypeORM migration:generate does not detect changes to named unique constraints.
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
await queryRunner.query(`DROP INDEX "IDX_ae34e6b153a90672eb9dc4857d"`);
await queryRunner.query(`DROP INDEX "IDX_6641da8d831b93dfcb429f8b8b"`);
await queryRunner.query(
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
);
await queryRunner.query(`DROP TABLE "watchlist"`);
await queryRunner.query(
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId")`
);
await queryRunner.query(
`CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById")`
);
await queryRunner.query(
`CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId")`
);
// Blocklist: revert to original
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"`
);
await queryRunner.query(
`CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blocklist"`);
await queryRunner.query(
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
);
await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`);
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"`
);
await queryRunner.query(
`CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blocklist"`);
await queryRunner.query(
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
);
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_09b94c932e84635c5461f3c0a9"`);
await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`);
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"`
);
await queryRunner.query(
`CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blocklist"`);
await queryRunner.query(
`CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") `
);
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") `
);
}
}

397
server/routes/auth.test.ts Normal file
View File

@@ -0,0 +1,397 @@
import assert from 'node:assert/strict';
import { before, beforeEach, describe, it, mock } from 'node:test';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import PreparedEmail from '@server/lib/email';
import { getSettings } from '@server/lib/settings';
import { checkUser } 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';
const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => {
return undefined;
}).mock;
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);
// Error handler matching how next({ status, message }) calls are handled
app.use(
(
err: { status?: number; message?: string },
_req: express.Request,
res: express.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: express.NextFunction
) => {
res
.status(err.status ?? 500)
.json({ status: err.status ?? 500, message: err.message });
}
);
return app;
}
before(async () => {
app = createApp();
});
setupTestDb();
/** Create a supertest agent that is logged in as the given user. */
async function authenticatedAgent(email: string, password: string) {
const agent = request.agent(app);
const settings = getSettings();
settings.main.localLogin = true;
const res = await agent.post('/auth/local').send({ email, password });
assert.strictEqual(res.status, 200);
return agent;
}
describe('GET /auth/me', () => {
it('returns 403 when not authenticated', async () => {
const res = await request(app).get('/auth/me');
assert.strictEqual(res.status, 403);
});
it('returns the authenticated user', async () => {
const agent = await authenticatedAgent('admin@seerr.dev', 'test1234');
const res = await agent.get('/auth/me');
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
assert.strictEqual(res.body.displayName, 'admin');
});
it('includes userEmailRequired warning when email is required but invalid', async () => {
const settings = getSettings();
settings.notifications.agents.email.options.userEmailRequired = true;
// Change the user's email to something invalid
const userRepo = getRepository(User);
const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});
user.email = 'not-an-email';
await userRepo.save(user);
// Log in with the changed email
const agent = request.agent(app);
settings.main.localLogin = true;
const loginRes = await agent
.post('/auth/local')
.send({ email: 'not-an-email', password: 'test1234' });
assert.strictEqual(loginRes.status, 200);
const res = await agent.get('/auth/me');
assert.strictEqual(res.status, 200);
assert.ok(res.body.warnings.includes('userEmailRequired'));
settings.notifications.agents.email.options.userEmailRequired = false;
});
});
describe('POST /auth/local', () => {
beforeEach(() => {
const settings = getSettings();
settings.main.localLogin = true;
});
it('returns 200 and user data on valid credentials', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
// filter() strips sensitive fields like password
assert.ok(!('password' in res.body));
});
it('returns 403 on wrong password', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'wrongpassword' });
assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.message, 'Access denied.');
});
it('returns 403 for nonexistent user', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'nobody@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.message, 'Access denied.');
});
it('returns 500 when local login is disabled', async () => {
const settings = getSettings();
settings.main.localLogin = false;
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.error, 'Password sign-in is disabled.');
});
it('returns 500 when email is missing', async () => {
const res = await request(app)
.post('/auth/local')
.send({ password: 'test1234' });
assert.strictEqual(res.status, 500);
assert.match(res.body.error, /email address and a password/);
});
it('returns 500 when password is missing', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev' });
assert.strictEqual(res.status, 500);
assert.match(res.body.error, /email address and a password/);
});
it('is case-insensitive for email', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'Admin@Seerr.Dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
});
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' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
});
it('sets a session on successful login', async () => {
const agent = request.agent(app);
await agent
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
// Session should persist — /me should succeed
const meRes = await agent.get('/auth/me');
assert.strictEqual(meRes.status, 200);
});
});
describe('POST /auth/logout', () => {
it('returns 200 when not logged in', async () => {
const res = await request(app).post('/auth/logout');
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
});
it('destroys session and returns 200 when logged in', async () => {
const agent = await authenticatedAgent('admin@seerr.dev', 'test1234');
// Verify session is active
const meBeforeRes = await agent.get('/auth/me');
assert.strictEqual(meBeforeRes.status, 200);
const logoutRes = await agent.post('/auth/logout');
assert.strictEqual(logoutRes.status, 200);
assert.strictEqual(logoutRes.body.status, 'ok');
// Session should be invalidated — /me should fail
const meAfterRes = await agent.get('/auth/me');
assert.strictEqual(meAfterRes.status, 403);
});
});
describe('POST /auth/reset-password', () => {
beforeEach(() => {
emailMock.resetCalls();
});
it('returns 200 for a valid email', async () => {
const res = await request(app)
.post('/auth/reset-password')
.send({ email: 'admin@seerr.dev' });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
assert.strictEqual(emailMock.callCount(), 1);
});
it('returns 200 for nonexistent email (does not reveal user existence)', async () => {
const res = await request(app)
.post('/auth/reset-password')
.send({ email: 'nonexistent@seerr.dev' });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
assert.strictEqual(emailMock.callCount(), 0);
});
it('returns 500 when email is missing', async () => {
const res = await request(app).post('/auth/reset-password').send({});
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.message, 'Email address required.');
assert.strictEqual(emailMock.callCount(), 0);
});
it('sets a resetPasswordGuid on the user', async () => {
await request(app)
.post('/auth/reset-password')
.send({ email: 'admin@seerr.dev' });
const userRepo = getRepository(User);
const user = await userRepo
.createQueryBuilder('user')
.addSelect(['user.resetPasswordGuid', 'user.recoveryLinkExpirationDate'])
.where('user.email = :email', { email: 'admin@seerr.dev' })
.getOneOrFail();
assert.notStrictEqual(user.resetPasswordGuid, undefined);
assert.notStrictEqual(user.resetPasswordGuid, null);
assert.notStrictEqual(user.recoveryLinkExpirationDate, undefined);
assert.strictEqual(emailMock.callCount(), 1);
});
});
describe('POST /auth/reset-password/:guid', () => {
/** Trigger a password reset and return the guid. */
async function getResetGuid(email: string): Promise<string> {
await request(app).post('/auth/reset-password').send({ email });
const userRepo = getRepository(User);
const user = await userRepo
.createQueryBuilder('user')
.addSelect('user.resetPasswordGuid')
.where('user.email = :email', { email })
.getOneOrFail();
return user.resetPasswordGuid!;
}
it('resets password with a valid guid and password', async () => {
const guid = await getResetGuid('admin@seerr.dev');
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'newpassword123' });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
// Old password no longer works
const oldLogin = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
assert.strictEqual(oldLogin.status, 403);
// New password works
const newLogin = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'newpassword123' });
assert.strictEqual(newLogin.status, 200);
});
it('returns 500 for an invalid guid', async () => {
const res = await request(app)
.post('/auth/reset-password/invalid-guid-here')
.send({ password: 'newpassword123' });
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.message, 'Invalid password reset link.');
});
it('returns 500 when password is too short', async () => {
const guid = await getResetGuid('admin@seerr.dev');
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'short' });
assert.strictEqual(res.status, 500);
assert.strictEqual(
res.body.message,
'Password must be at least 8 characters long.'
);
});
it('returns 500 when password is missing', async () => {
const guid = await getResetGuid('admin@seerr.dev');
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({});
assert.strictEqual(res.status, 500);
assert.strictEqual(
res.body.message,
'Password must be at least 8 characters long.'
);
});
it('returns 500 for an expired recovery link', async () => {
const guid = await getResetGuid('admin@seerr.dev');
// Expire the link
const userRepo = getRepository(User);
const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});
user.recoveryLinkExpirationDate = new Date('2020-01-01');
await userRepo.save(user);
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'newpassword123' });
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.message, 'Invalid password reset link.');
});
it('cannot reuse a guid after successful reset', async () => {
const guid = await getResetGuid('admin@seerr.dev');
// First reset succeeds
const first = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'newpassword123' });
assert.strictEqual(first.status, 200);
// Second reset with same guid fails (recoveryLinkExpirationDate was cleared)
const second = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'anotherpassword' });
assert.strictEqual(second.status, 500);
});
});

View File

@@ -671,9 +671,11 @@ 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="${getAppVersion()}", Token="${
settings.jellyfin.apiKey
}"`,
'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="seerr", Version="${
settings.main.mediaServerType === MediaServerType.EMBY
? '1.0.0'
: getAppVersion()
}", Token="${settings.jellyfin.apiKey}"`,
},
});
} catch (error) {
@@ -738,7 +740,7 @@ authRoutes.post('/reset-password', async (req, res, next) => {
if (user) {
await user.resetPassword();
userRepository.save(user);
await userRepository.save(user);
logger.info('Successfully sent password reset link', {
label: 'API',
ip: req.ip,
@@ -803,7 +805,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
}
user.recoveryLinkExpirationDate = null;
await user.setPassword(req.body.password);
userRepository.save(user);
await userRepository.save(user);
logger.info('Successfully reset password', {
label: 'API',
ip: req.ip,

View File

@@ -27,7 +27,11 @@ async function initAvatarImageProxy() {
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`,
'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${deviceId}", Version="${
getSettings().main.mediaServerType === MediaServerType.EMBY
? '1.0.0'
: getAppVersion()
}", Token="${authToken}"`,
},
});
}
@@ -61,7 +65,7 @@ export async function checkAvatarChanged(
if (headResponse.status !== 200) {
return { changed: false };
}
} catch (error) {
} catch {
return { changed: false };
}
@@ -141,13 +145,16 @@ router.get('/:jellyfinUserId', async (req, res) => {
const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId);
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
fallbackUrl
);
if (imageData.meta.extension === 'json') {
// this is a 404
let imageData;
if (user?.avatarVersion) {
imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
fallbackUrl
);
if (imageData.meta.extension === 'json') {
imageData = await avatarImageCache.getImage(fallbackUrl);
}
} else {
imageData = await avatarImageCache.getImage(fallbackUrl);
}

Some files were not shown because too many files have changed in this diff Show More