mirror of
https://github.com/seerr-team/seerr.git
synced 2026-06-16 12:30:37 -04:00
chore(release): merge develop into main
This commit is contained in:
69
.eslintrc.js
69
.eslintrc.js
@@ -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
24
.github/cliff.toml
vendored
@@ -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]+.*"
|
||||
|
||||
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -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 }}'
|
||||
|
||||
4
.github/workflows/create-tag.yml
vendored
4
.github/workflows/create-tag.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/docs-link-check.yml
vendored
2
.github/workflows/docs-link-check.yml
vendored
@@ -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: >-
|
||||
|
||||
10
.github/workflows/helm.yml
vendored
10
.github/workflows/helm.yml
vendored
@@ -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
285
.github/workflows/pr-validation.yml
vendored
Normal 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,
|
||||
});
|
||||
}
|
||||
8
.github/workflows/preview.yml
vendored
8
.github/workflows/preview.yml
vendored
@@ -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
|
||||
|
||||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -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: |
|
||||
|
||||
28
.github/workflows/semantic-pr.yml
vendored
28
.github/workflows/semantic-pr.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: "Semantic PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR Title
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: write
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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'
|
||||
|
||||
4
.github/workflows/trivy-scan.yml
vendored
4
.github/workflows/trivy-scan.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
lcov.info
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -16,6 +16,7 @@
|
||||
|
||||
"stylelint.vscode-stylelint",
|
||||
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"firsttris.vscode-jest-runner"
|
||||
]
|
||||
}
|
||||
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -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": "."
|
||||
}
|
||||
|
||||
101
CONTRIBUTING.md
101
CONTRIBUTING.md
@@ -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/).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
92
bin/check-pr-template.mjs
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# seerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
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 | `[]` | |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"clientId": "6919275e-142a-48d8-be6b-93594cbd4626",
|
||||
"sessionSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||
"main": {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
96
docs/getting-started/nixpkg.mdx
Normal file
96
docs/getting-started/nixpkg.mdx
Normal 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.
|
||||
:::
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: AUR (Advanced)
|
||||
description: Install Seerr using the Arch User Repository
|
||||
sidebar_position: 2
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# AUR
|
||||
|
||||
@@ -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)
|
||||
-->
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Synology (Advanced)
|
||||
description: Install Seerr on Synology NAS using SynoCommunity
|
||||
sidebar_position: 5
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Synology
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: TrueNAS (Advanced)
|
||||
description: Install Seerr using TrueNAS
|
||||
sidebar_position: 4
|
||||
sidebar_position: 3
|
||||
---
|
||||
# TrueNAS
|
||||
:::warning
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Unraid (Advanced)
|
||||
description: Install Seerr using Unraid
|
||||
sidebar_position: 3
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
|
||||
@@ -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.
|
||||
|
||||
61
docs/using-seerr/advanced/self-signed-certificates.mdx
Normal file
61
docs/using-seerr/advanced/self-signed-certificates.mdx
Normal 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).
|
||||
@@ -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.
|
||||
:::
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
60
docs/using-seerr/settings/network.md
Normal file
60
docs/using-seerr/settings/network.md
Normal 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.
|
||||
@@ -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
95
eslint.config.mts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
72
gen-docs/blog/2026-02-28-seerr-security-fix-release.md
Normal file
72
gen-docs/blog/2026-02-28-seerr-security-fix-release.md
Normal 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.
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
29
package.json
29
package.json
@@ -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
9036
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
118
seerr-api.yml
118
seerr-api.yml
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
}
|
||||
|
||||
return tmdbTvShow;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return tmdbTvShow;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -203,7 +203,7 @@ app
|
||||
server.use(
|
||||
'/api',
|
||||
session({
|
||||
secret: settings.clientId,
|
||||
secret: settings.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface PublicSettingsResponse {
|
||||
emailEnabled: boolean;
|
||||
newPlexLogin: boolean;
|
||||
youtubeUrl: string;
|
||||
plexClientIdentifier: string;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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
397
server/routes/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user