Compare commits
12 Commits
preview-ty
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02755f03d | ||
|
|
f2c771727c | ||
|
|
87b51b809b | ||
|
|
43553cb2d5 | ||
|
|
8bb7d4e380 | ||
|
|
8c4e39d098 | ||
|
|
973e43f1cc | ||
|
|
a93716eb15 | ||
|
|
6000c36c69 | ||
|
|
6c9aaf9777 | ||
|
|
c4d06540a6 | ||
|
|
98a6075cb6 |
@@ -16,7 +16,6 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
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,
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,14 +91,6 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this bug has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,14 +27,6 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this feature has already been requested?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
77
.github/workflows/ci.yml
vendored
77
.github/workflows/ci.yml
vendored
@@ -22,77 +22,14 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
i18n:
|
||||
name: i18n Check
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.pull_request.number }}
|
||||
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: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version-file: 'package.json'
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@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: i18n Check
|
||||
shell: bash
|
||||
env:
|
||||
BODY: |
|
||||
The i18n check failed because translation messages are out of sync.
|
||||
|
||||
This usually happens when you've added or modified translation strings in your code but haven't updated the translation file.
|
||||
|
||||
Please run `pnpm i18n:extract` and commit the changes.
|
||||
run: |
|
||||
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
|
||||
node bin/check-i18n.js
|
||||
check_failed=$?
|
||||
if [ $check_failed -eq 1 ]; then
|
||||
retry gh pr edit "$NUMBER" -R "$GH_REPO" --add-label "i18n-out-of-sync" || true
|
||||
retry gh pr comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
|
||||
else
|
||||
retry gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "i18n-out-of-sync" || true
|
||||
fi
|
||||
exit $check_failed
|
||||
|
||||
test:
|
||||
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.20.0-alpine3.22@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -141,7 +78,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -150,7 +87,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -177,7 +114,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -186,7 +123,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -203,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -37,20 +37,20 @@ jobs:
|
||||
language: [actions, javascript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
4
.github/workflows/cypress.yml
vendored
4
.github/workflows/cypress.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run: pnpm exec cypress install
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
|
||||
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2
|
||||
with:
|
||||
install: false
|
||||
build: pnpm cypress:build
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
name: Build Docusaurus
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/docs-link-check.yml
vendored
4
.github/workflows/docs-link-check.yml
vendored
@@ -36,13 +36,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Lychee link checker
|
||||
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
|
||||
uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
|
||||
with:
|
||||
fail: false
|
||||
args: >-
|
||||
|
||||
6
.github/workflows/helm.yml
vendored
6
.github/workflows/helm.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/lint-helm-charts.yml
vendored
4
.github/workflows/lint-helm-charts.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
|
||||
- name: Ensure documentation is updated
|
||||
uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c
|
||||
|
||||
10
.github/workflows/preview.yml
vendored
10
.github/workflows/preview.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Derive preview version from tag
|
||||
id: ver
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
|
||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -27,14 +27,14 @@ jobs:
|
||||
release_body: ${{ steps.git-cliff.outputs.content }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate changelog
|
||||
id: git-cliff
|
||||
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
|
||||
uses: orhun/git-cliff-action@d77b37db2e3f7398432d34b72a12aa3e2ba87e51 # v4.6.0
|
||||
with:
|
||||
config: .github/cliff.toml
|
||||
args: -vv --current
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
needs: changelog
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Warm cache [${{ matrix.platform }}]
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
COSIGN_YES: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Install Trivy
|
||||
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
|
||||
uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 # v0.2.4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
@@ -25,19 +25,19 @@ jobs:
|
||||
if: github.actor == 'renovate[bot]'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
id: app-token
|
||||
with:
|
||||
app-id: 2138788
|
||||
private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
|
||||
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@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
any-of-labels: "pending author's response"
|
||||
exempt-issue-labels: 'confirmed'
|
||||
|
||||
2
.github/workflows/test-docs-deploy.yml
vendored
2
.github/workflows/test-docs-deploy.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/trivy-scan.yml
vendored
4
.github/workflows/trivy-scan.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -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@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
with:
|
||||
sarif_file: trivy.sarif
|
||||
|
||||
@@ -4,17 +4,15 @@ dist/
|
||||
config/
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
.github
|
||||
.vscode
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
docs/
|
||||
public/*
|
||||
public/
|
||||
!public/sw.js
|
||||
docs/
|
||||
!/public/
|
||||
/public/*
|
||||
!/public/sw.js
|
||||
|
||||
# helm charts
|
||||
**/charts
|
||||
|
||||
# Prettier breaks GitHub alert syntax in markdown
|
||||
*.md
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
|
||||
plugins: [require('./merged-prettier-plugin.js')],
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
overrides: [
|
||||
@@ -27,11 +27,5 @@ module.exports = {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'public/offline.html',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7 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.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
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.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
22
README.md
22
README.md
@@ -8,7 +8,7 @@
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
|
||||
|
||||
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -32,28 +32,10 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
> [!WARNING]
|
||||
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||
|
||||
Instead, follow the dedicated migration guide (with `:develop` tag):
|
||||
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||
|
||||
> [!CAUTION]
|
||||
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
|
||||
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
|
||||
|
||||
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Check that i18n locale files are in sync with extracted messages.
|
||||
* Runs `pnpm i18n:extract` and compares en.json; exits 1 if they differ.
|
||||
*/
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const localePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'i18n',
|
||||
'locale',
|
||||
'en.json'
|
||||
);
|
||||
const backupPath = `${localePath}.bak`;
|
||||
|
||||
try {
|
||||
fs.copyFileSync(localePath, backupPath);
|
||||
execSync('pnpm i18n:extract', { stdio: 'inherit' });
|
||||
const original = fs.readFileSync(backupPath, 'utf8');
|
||||
const extracted = fs.readFileSync(localePath, 'utf8');
|
||||
fs.unlinkSync(backupPath);
|
||||
|
||||
if (original !== extracted) {
|
||||
console.error(
|
||||
"i18n messages are out of sync. Please run 'pnpm i18n:extract' and commit the changes."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -38,13 +38,6 @@ For details on the Docker CLI, please [review the official `docker run` document
|
||||
|
||||
#### Installation:
|
||||
|
||||
```bash
|
||||
# Create the appdata folder
|
||||
mkdir /path/to/appdata/config
|
||||
# Chown the folder as the container runs as the `node` user (UID 1000).
|
||||
chown -R 1000:1000 /path/to/appdata/config
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name seerr \
|
||||
@@ -55,16 +48,20 @@ docker run -d \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/seerr-team/seerr:latest
|
||||
```
|
||||
|
||||
The argument `-e PORT=5055` is optional.
|
||||
|
||||
If you want to add a healthcheck to the above command, you can add the following flags :
|
||||
```
|
||||
--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 \
|
||||
ghcr.io/seerr-team/seerr:latest
|
||||
```
|
||||
|
||||
The argument `-e PORT=5055` is optional.
|
||||
|
||||
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
||||
|
||||
#### Updating:
|
||||
@@ -118,13 +115,6 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```bash
|
||||
# Create the appdata folder
|
||||
mkdir /path/to/appdata/config
|
||||
# Chown the folder as the container runs as the `node` user (UID 1000).
|
||||
chown -R 1000:1000 /path/to/appdata/config
|
||||
```
|
||||
|
||||
Then, start all services defined in the Compose file:
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -176,16 +166,20 @@ docker run -d \
|
||||
-p 5055:5055 \
|
||||
-v seerr-data:/app/config \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/seerr-team/seerr:latest
|
||||
```
|
||||
|
||||
The argument `-e PORT=5055` is optional.
|
||||
|
||||
If you want to add a healthcheck to the above command, you can add the following flags :
|
||||
```
|
||||
--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 \
|
||||
ghcr.io/seerr-team/seerr:latest
|
||||
```
|
||||
|
||||
The argument `-e PORT=5055` is optional.
|
||||
|
||||
#### Updating:
|
||||
Pull the latest image:
|
||||
```bash
|
||||
|
||||
@@ -7,9 +7,5 @@ import DocCardList from '@theme/DocCardList';
|
||||
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
|
||||
:::
|
||||
|
||||
:::info
|
||||
Want to add a third-party installation method? Contributions are welcome! Feel free to open a pull request.
|
||||
:::
|
||||
|
||||
|
||||
<DocCardList />
|
||||
|
||||
@@ -10,21 +10,8 @@ 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,20 +0,0 @@
|
||||
---
|
||||
title: TrueNAS (Advanced)
|
||||
description: Install Seerr using TrueNAS
|
||||
sidebar_position: 4
|
||||
---
|
||||
# TrueNAS
|
||||
:::danger
|
||||
This method has not yet been updated for Seerr and is currently a work in progress.
|
||||
You can follow the ongoing work on this issue https://github.com/truenas/apps/issues/3374.
|
||||
:::
|
||||
|
||||
<!--
|
||||
:::warning
|
||||
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
This method is not recommended for most users. It is intended for advanced users who are using TrueNAS distribution.
|
||||
:::
|
||||
-->
|
||||
@@ -5,12 +5,6 @@ sidebar_position: 3
|
||||
---
|
||||
|
||||
# Unraid
|
||||
:::danger
|
||||
This method has not yet been updated for Seerr and is awaiting a community contribution.
|
||||
Feel free to open a pull request on GitHub to update this installation method.
|
||||
:::
|
||||
|
||||
<!--
|
||||
:::warning
|
||||
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
|
||||
:::
|
||||
@@ -24,4 +18,3 @@ This method is not recommended for most users. It is intended for advanced users
|
||||
3. Click the **Install Button**.
|
||||
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
|
||||
5. Click apply and access "Seerr" at your `<ServerIP:HostPort>` in a web browser.
|
||||
-->
|
||||
|
||||
@@ -9,20 +9,11 @@ Whether you come from Overseerr or Jellyseerr, you don't need to perform any man
|
||||
This migration will run automatically the first time you start your instance using the Seerr codebase (Docker image or source build or Kubernetes, etc.).
|
||||
An additional migration will happen for Overseerr users, to migrate their configuration to the new codebase.
|
||||
|
||||
:::danger
|
||||
:::warning
|
||||
Before doing anything you should backup your existing instance so that you can rollback in case something goes wrong.
|
||||
See [Backups](/using-seerr/backups) for details on how to properly backup your instance.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
Installation methods are now divided into two categories: official and third-party methods.
|
||||
The Seerr team is only responsible for official installation methods, while third-party methods are maintained by the community.
|
||||
Some methods are currently not maintained, but this does not mean they are permanently discontinued. The community may restore and support them if they choose to do so.
|
||||
|
||||
- **Unraid app:** Not maintained
|
||||
- **Snap package:** Not maintained
|
||||
:::
|
||||
|
||||
## Docker
|
||||
Refer to [Seerr Docker Documentation](/getting-started/docker), all of our examples have been updated to reflect the below change.
|
||||
|
||||
@@ -32,9 +23,8 @@ Changes :
|
||||
- The container can now be run as a non-root user (`node` user); remove the `user` directive if you have configured it.
|
||||
- The container no longer provides an init process, so you must configure it by adding `init: true` for Docker Compose or `--init` for the Docker CLI.
|
||||
|
||||
#### Config folder permissions
|
||||
:::info
|
||||
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.
|
||||
**Config folder permissions**: 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
|
||||
@@ -136,12 +126,6 @@ Summary of changes :
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Build From Source
|
||||
Refer to [Seerr Build From Source Documentation](/getting-started/buildfromsource), all of our examples have been updated to reflect the below change.
|
||||
|
||||
Install from scratch by following the documentation, restore your data as described in [Backups](/using-seerr/backups), and then start Seerr. No additional steps are required.
|
||||
|
||||
## Kubernetes
|
||||
Refer to [Seerr Kubernetes Documentation](/getting-started/kubernetes), all of our examples have been updated to reflect the below change.
|
||||
|
||||
@@ -182,15 +166,3 @@ Summary of changes :
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Nix (Third-party installation methods)
|
||||
|
||||
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
|
||||
|
||||
### AUR (Third-party installation methods)
|
||||
|
||||
See https://aur.archlinux.org/packages/seerr
|
||||
|
||||
### TrueNAS (Third-party installation methods)
|
||||
|
||||
Waiting for https://github.com/truenas/apps/issues/3374
|
||||
|
||||
@@ -7,7 +7,7 @@ Seerr docs will be available at [docs.seerr.dev](https://docs.seerr.dev).
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ pnpm install
|
||||
$ pnpm
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
|
||||
.table-of-contents__link--active,
|
||||
a:not(
|
||||
.card,
|
||||
.menu__link,
|
||||
.menu__link--sublist,
|
||||
.menu__link--sublist-item,
|
||||
.table-of-contents__link
|
||||
) {
|
||||
.card,
|
||||
.menu__link,
|
||||
.menu__link--sublist,
|
||||
.menu__link--sublist-item,
|
||||
.table-of-contents__link
|
||||
) {
|
||||
/* color: #793ae8; */
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
21
merged-prettier-plugin.js
Normal file
21
merged-prettier-plugin.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
const tailwind = require('prettier-plugin-tailwindcss');
|
||||
const organizeImports = require('prettier-plugin-organize-imports');
|
||||
|
||||
const combinedFormatter = {
|
||||
...tailwind,
|
||||
parsers: {
|
||||
...tailwind.parsers,
|
||||
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
...tailwind.parsers[key],
|
||||
preprocess(code, options) {
|
||||
return organizeImports.parsers[key].preprocess(code, options);
|
||||
},
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = combinedFormatter;
|
||||
79
package.json
79
package.json
@@ -5,6 +5,7 @@
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||
"build:next": "next build",
|
||||
@@ -16,7 +17,7 @@
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||
"format": "prettier --log-level warn --write --cache .",
|
||||
"format": "prettier --loglevel warn --write --cache .",
|
||||
"format:check": "prettier --check --cache .",
|
||||
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
|
||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||
@@ -37,18 +38,18 @@
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.4.6",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.56",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/wink-jaro-distance": "^2.0.2",
|
||||
"ace-builds": "1.43.4",
|
||||
"axios": "1.13.3",
|
||||
"axios": "1.13.2",
|
||||
"axios-rate-limit": "1.4.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.13.1",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.7",
|
||||
@@ -67,15 +68,16 @@
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lodash": "4.17.23",
|
||||
"lodash": "4.17.21",
|
||||
"mime": "3",
|
||||
"next": "^14.2.35",
|
||||
"next": "^14.2.25",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "7.0.12",
|
||||
"openpgp": "6.3.0",
|
||||
"pg": "8.17.2",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.16.3",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
@@ -88,6 +90,7 @@
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.10.2",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
@@ -98,28 +101,28 @@
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.3.8",
|
||||
"swr": "2.3.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.28",
|
||||
"typeorm": "0.3.12",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"undici": "^7.18.2",
|
||||
"undici": "^7.16.0",
|
||||
"validator": "^13.15.23",
|
||||
"web-push": "3.6.7",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston": "3.18.3",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.5.0",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "4.3.6"
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/country-flag-icons": "1.2.2",
|
||||
"@types/csurf": "1.11.5",
|
||||
@@ -130,7 +133,7 @@
|
||||
"@types/mime": "3",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"@types/nodemailer": "7",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
@@ -139,20 +142,20 @@
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.22",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "14.5.4",
|
||||
"cypress": "14.1.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
@@ -163,20 +166,24 @@
|
||||
"husky": "8.0.3",
|
||||
"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",
|
||||
"tailwindcss": "3.4.19",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
"pnpm": "^10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.18.2"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
@@ -203,10 +210,6 @@
|
||||
"cypress",
|
||||
"sharp",
|
||||
"sqlite3"
|
||||
],
|
||||
"overrides": {
|
||||
"sqlite3>node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.18.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6208
pnpm-lock.yaml
generated
6208
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
13
postinstall-win.js
Normal file
13
postinstall-win.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const typeormPath = path.resolve('node_modules/typeorm');
|
||||
|
||||
if (fs.existsSync(typeormPath)) {
|
||||
process.stdout.write('> Installing typeorm@0.3.11 for Windows\n');
|
||||
execSync('pnpm add typeorm@0.3.11', { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
111
seerr-api.yml
111
seerr-api.yml
@@ -3984,6 +3984,85 @@ paths:
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
/auth/jellyfin/quickconnect/initiate:
|
||||
post:
|
||||
summary: Initiate Jellyfin Quick Connect
|
||||
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: Quick Connect session initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: '123456'
|
||||
secret:
|
||||
type: string
|
||||
example: 'abc123def456'
|
||||
'500':
|
||||
description: Failed to initiate Quick Connect
|
||||
/auth/jellyfin/quickconnect/check:
|
||||
get:
|
||||
summary: Check Quick Connect authorization status
|
||||
description: Checks if the Quick Connect code has been authorized by the user.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: query
|
||||
name: secret
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The secret returned from the initiate endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Authorization status returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
example: false
|
||||
'404':
|
||||
description: Quick Connect session not found or expired
|
||||
/auth/jellyfin/quickconnect/authenticate:
|
||||
post:
|
||||
summary: Authenticate with Quick Connect
|
||||
description: Completes the Quick Connect authentication flow and creates a user session.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'403':
|
||||
description: Quick Connect not authorized or access denied
|
||||
'500':
|
||||
description: Authentication failed
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Sign in using a local account
|
||||
@@ -4913,6 +4992,38 @@ paths:
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/jellyfin/quickconnect:
|
||||
post:
|
||||
summary: Link Jellyfin/Emby account with Quick Connect
|
||||
description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'204':
|
||||
description: Account successfully linked
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'422':
|
||||
description: Account already linked
|
||||
'500':
|
||||
description: Server error
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
|
||||
@@ -13,7 +13,6 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
export interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
rateLimit?: {
|
||||
maxRPS: number;
|
||||
maxRequests: number;
|
||||
@@ -33,7 +32,6 @@ class ExternalAPI {
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
params,
|
||||
timeout: options.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
|
||||
@@ -57,7 +57,7 @@ interface GithubCommit {
|
||||
sha: string;
|
||||
url: string;
|
||||
html_url: string;
|
||||
},
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
|
||||
AccessToken: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectInitiateResponse {
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectStatusResponse {
|
||||
Authenticated: boolean;
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DeviceId: string;
|
||||
DeviceName: string;
|
||||
AppName: string;
|
||||
AppVersion: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface JellyfinUserListResponse {
|
||||
users: JellyfinUserResponse[];
|
||||
}
|
||||
@@ -216,6 +233,62 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
|
||||
try {
|
||||
const response = await this.post<QuickConnectInitiateResponse>(
|
||||
'/QuickConnect/Initiate'
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while initiating Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuickConnect(
|
||||
secret: string
|
||||
): Promise<QuickConnectStatusResponse> {
|
||||
try {
|
||||
const response = await this.get<QuickConnectStatusResponse>(
|
||||
'/QuickConnect/Connect',
|
||||
{ params: { secret } }
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting Quick Connect status: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async authenticateQuickConnect(
|
||||
secret: string
|
||||
): Promise<JellyfinLoginResponse> {
|
||||
try {
|
||||
const response = await this.post<JellyfinLoginResponse>(
|
||||
'/Users/AuthenticateWithQuickConnect',
|
||||
{ Secret: secret }
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public setUserId(userId: string): void {
|
||||
this.userId = userId;
|
||||
return;
|
||||
@@ -420,7 +493,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
public async getEpisodes<
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined,
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined
|
||||
>(
|
||||
seriesID: string,
|
||||
seasonID: string,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { Library, PlexSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
interface PlexStatusResponse {
|
||||
MediaContainer: {
|
||||
machineIdentifier: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
import NodePlexAPI from 'plex-api';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -91,7 +84,9 @@ interface PlexMetadataResponse {
|
||||
};
|
||||
}
|
||||
|
||||
class PlexAPI extends ExternalAPI {
|
||||
class PlexAPI {
|
||||
private plexClient: NodePlexAPI;
|
||||
|
||||
constructor({
|
||||
plexToken,
|
||||
plexSettings,
|
||||
@@ -102,33 +97,48 @@ class PlexAPI extends ExternalAPI {
|
||||
timeout?: number;
|
||||
}) {
|
||||
const settings = getSettings();
|
||||
const settingsPlex = plexSettings ?? settings.plex;
|
||||
let settingsPlex: PlexSettings | undefined;
|
||||
plexSettings
|
||||
? (settingsPlex = plexSettings)
|
||||
: (settingsPlex = getSettings().plex);
|
||||
|
||||
const protocol = settingsPlex.useSsl ? 'https' : 'http';
|
||||
const baseUrl = `${protocol}://${settingsPlex.ip}:${settingsPlex.port}`;
|
||||
|
||||
super(
|
||||
baseUrl,
|
||||
{},
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
'X-Plex-Token': plexToken ?? '',
|
||||
'X-Plex-Client-Identifier': settings.clientId,
|
||||
'X-Plex-Product': 'Seerr',
|
||||
'X-Plex-Device-Name': 'Seerr',
|
||||
'X-Plex-Platform': 'Seerr',
|
||||
this.plexClient = new NodePlexAPI({
|
||||
hostname: settingsPlex.ip,
|
||||
port: settingsPlex.port,
|
||||
https: settingsPlex.useSsl,
|
||||
timeout: timeout,
|
||||
token: plexToken ?? undefined,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi,
|
||||
cb: (err?: string, token?: string) => void
|
||||
) => {
|
||||
if (!plexToken) {
|
||||
return cb('Plex Token not found!');
|
||||
}
|
||||
cb(undefined, plexToken);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
// requestOptions: {
|
||||
// includeChildren: 1,
|
||||
// },
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Seerr',
|
||||
deviceName: 'Seerr',
|
||||
platform: 'Seerr',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getStatus(): Promise<PlexStatusResponse> {
|
||||
return await this.get('/');
|
||||
public async getStatus() {
|
||||
return await this.plexClient.query('/');
|
||||
}
|
||||
|
||||
public async getLibraries(): Promise<PlexLibrary[]> {
|
||||
const response = await this.get<PlexLibrariesResponse>('/library/sections');
|
||||
const response = await this.plexClient.query<PlexLibrariesResponse>(
|
||||
'/library/sections'
|
||||
);
|
||||
|
||||
return response.MediaContainer.Directory;
|
||||
}
|
||||
@@ -177,15 +187,13 @@ class PlexAPI extends ExternalAPI {
|
||||
id: string,
|
||||
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
|
||||
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?includeGuids=1`,
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Container-Start': `${offset}`,
|
||||
'X-Plex-Container-Size': `${size}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?includeGuids=1`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `${offset}`,
|
||||
'X-Plex-Container-Size': `${size}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
totalSize: response.MediaContainer.totalSize,
|
||||
@@ -197,7 +205,7 @@ class PlexAPI extends ExternalAPI {
|
||||
key: string,
|
||||
options: { includeChildren?: boolean } = {}
|
||||
): Promise<PlexMetadata> {
|
||||
const response = await this.get<PlexMetadataResponse>(
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}${
|
||||
options.includeChildren ? '?includeChildren=1' : ''
|
||||
}`
|
||||
@@ -207,7 +215,7 @@ class PlexAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
|
||||
const response = await this.get<PlexMetadataResponse>(
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}/children`
|
||||
);
|
||||
|
||||
@@ -221,17 +229,15 @@ class PlexAPI extends ExternalAPI {
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?type=${
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Container-Start': '0',
|
||||
'X-Plex-Container-Size': '500',
|
||||
},
|
||||
}
|
||||
);
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const mapSounds = (sounds: {
|
||||
({
|
||||
name,
|
||||
description,
|
||||
}) as PushoverSound
|
||||
} as PushoverSound)
|
||||
);
|
||||
|
||||
class PushoverAPI extends ExternalAPI {
|
||||
|
||||
@@ -157,8 +157,8 @@ class RottenTomatoes extends ExternalAPI {
|
||||
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||
? 'Certified Fresh'
|
||||
: movie.rottenTomatoes.criticsScore >= 60
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||
audienceRating:
|
||||
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
|
||||
@@ -92,13 +92,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
apiKey,
|
||||
cacheName,
|
||||
apiName,
|
||||
timeout = 5000,
|
||||
}: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
cacheName: AvailableCacheIds;
|
||||
apiName: string;
|
||||
timeout?: number;
|
||||
}) {
|
||||
super(
|
||||
url,
|
||||
@@ -107,7 +105,6 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache(cacheName).data,
|
||||
timeout,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -64,16 +64,8 @@ export interface RadarrMovie {
|
||||
}
|
||||
|
||||
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
constructor({
|
||||
url,
|
||||
apiKey,
|
||||
timeout,
|
||||
}: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
}) {
|
||||
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr', timeout });
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
|
||||
}
|
||||
|
||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||
|
||||
@@ -111,16 +111,8 @@ class SonarrAPI extends ServarrBase<{
|
||||
episodeId: number;
|
||||
episode: EpisodeResult;
|
||||
}> {
|
||||
constructor({
|
||||
url,
|
||||
apiKey,
|
||||
timeout,
|
||||
}: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
}) {
|
||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr', timeout });
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||
}
|
||||
|
||||
public async getSeries(): Promise<SonarrSeries[]> {
|
||||
@@ -217,34 +209,6 @@ class SonarrAPI extends ServarrBase<{
|
||||
series: newSeriesResponse.data,
|
||||
});
|
||||
|
||||
try {
|
||||
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
|
||||
const episodeIdsToMonitor = episodes
|
||||
.filter(
|
||||
(ep) =>
|
||||
options.seasons.includes(ep.seasonNumber) && !ep.monitored
|
||||
)
|
||||
.map((ep) => ep.id);
|
||||
|
||||
if (episodeIdsToMonitor.length > 0) {
|
||||
logger.debug(
|
||||
'Re-monitoring unmonitored episodes for requested seasons.',
|
||||
{
|
||||
label: 'Sonarr',
|
||||
seriesId: newSeriesResponse.data.id,
|
||||
episodeCount: episodeIdsToMonitor.length,
|
||||
}
|
||||
);
|
||||
await this.monitorEpisodes(episodeIdsToMonitor);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to re-monitor episodes', {
|
||||
label: 'Sonarr',
|
||||
errorMessage: e.message,
|
||||
seriesId: newSeriesResponse.data.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.searchNow) {
|
||||
this.searchSeries(newSeriesResponse.data.id);
|
||||
}
|
||||
@@ -354,38 +318,6 @@ class SonarrAPI extends ServarrBase<{
|
||||
}
|
||||
}
|
||||
|
||||
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
|
||||
try {
|
||||
const response = await this.axios.get<EpisodeResult[]>('/episode', {
|
||||
params: { seriesId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
logger.error('Failed to retrieve episodes', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
seriesId,
|
||||
});
|
||||
throw new Error('Failed to get episodes');
|
||||
}
|
||||
}
|
||||
|
||||
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
|
||||
try {
|
||||
await this.axios.put('/episode/monitor', {
|
||||
episodeIds,
|
||||
monitored: true,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to monitor episodes', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
episodeIds,
|
||||
});
|
||||
throw new Error('Failed to monitor episodes');
|
||||
}
|
||||
}
|
||||
|
||||
private buildSeasonList(
|
||||
seasons: number[],
|
||||
existingSeasons?: SonarrSeason[]
|
||||
|
||||
@@ -269,8 +269,8 @@ class TautulliAPI {
|
||||
recordA.grandparent_rating_key && recordB.grandparent_rating_key
|
||||
? recordA.grandparent_rating_key === recordB.grandparent_rating_key
|
||||
: recordA.parent_rating_key && recordB.parent_rating_key
|
||||
? recordA.parent_rating_key === recordB.parent_rating_key
|
||||
: recordA.rating_key === recordB.rating_key
|
||||
? recordA.parent_rating_key === recordB.parent_rating_key
|
||||
: recordA.rating_key === recordB.rating_key
|
||||
);
|
||||
|
||||
start += take;
|
||||
|
||||
@@ -536,8 +536,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'primary_release_date.gte':
|
||||
@@ -630,8 +630,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
|
||||
@@ -392,10 +392,8 @@ export interface TmdbPersonCombinedCredits {
|
||||
crew: TmdbPersonCreditCrew[];
|
||||
}
|
||||
|
||||
export interface TmdbSeasonWithEpisodes extends Omit<
|
||||
TmdbTvSeasonResult,
|
||||
'episode_count'
|
||||
> {
|
||||
export interface TmdbSeasonWithEpisodes
|
||||
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
|
||||
episodes: TmdbTvEpisodeResult[];
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
import { Permission, hasPermission } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { AfterDate } from '@server/utils/dateHelpers';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
@@ -271,7 +271,7 @@ export class User {
|
||||
});
|
||||
|
||||
const movieQuotaLimit = !canBypass
|
||||
? (this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit)
|
||||
? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
|
||||
: 0;
|
||||
const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
|
||||
|
||||
@@ -295,7 +295,7 @@ export class User {
|
||||
: 0;
|
||||
|
||||
const tvQuotaLimit = !canBypass
|
||||
? (this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit)
|
||||
? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
|
||||
: 0;
|
||||
const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -97,10 +97,7 @@ app
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(
|
||||
settings.network.proxy,
|
||||
settings.network.forceIpv4First
|
||||
);
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
|
||||
@@ -7,10 +7,6 @@ export interface RequestResultsResponse extends PaginatedResponse {
|
||||
profileName?: string;
|
||||
canRemove?: boolean;
|
||||
})[];
|
||||
serviceErrors: {
|
||||
radarr: { id: number; name: string }[];
|
||||
sonarr: { id: number; name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export type MediaRequestBody = {
|
||||
|
||||
@@ -300,6 +300,7 @@ class AvailabilitySync {
|
||||
// Sonarr finds that season, we will change the final seasons value
|
||||
// to true.
|
||||
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
@@ -310,7 +311,48 @@ class AvailabilitySync {
|
||||
filteredSeasonsMap.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
// non-4k
|
||||
const finalSeasons: Map<number, boolean> = new Map();
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
plexSeasonsMap.forEach((value, key) => {
|
||||
finalSeasons.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
jellyfinSeasonsMap.forEach((value, key) => {
|
||||
finalSeasons.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
@@ -321,32 +363,44 @@ class AvailabilitySync {
|
||||
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
let finalSeasons: Map<number, boolean>;
|
||||
let finalSeasons4k: Map<number, boolean>;
|
||||
// 4k
|
||||
const finalSeasons4k: Map<number, boolean> = new Map();
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...plexSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...plexSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
} else {
|
||||
// Jellyfin/Emby
|
||||
finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...jellyfinSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...jellyfinSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
plexSeasonsMap4k.forEach((value, key) => {
|
||||
finalSeasons4k.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
jellyfinSeasonsMap4k.forEach((value, key) => {
|
||||
finalSeasons4k.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -513,8 +567,8 @@ class AvailabilitySync {
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -588,8 +642,8 @@ class AvailabilitySync {
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -612,13 +666,6 @@ class AvailabilitySync {
|
||||
): Promise<boolean> {
|
||||
let existsInRadarr = false;
|
||||
|
||||
const hasSameServerInBothModes = this.radarrServers.some((a) =>
|
||||
this.radarrServers.some(
|
||||
(b) =>
|
||||
a.is4k !== b.is4k && a.hostname === b.hostname && a.port === b.port
|
||||
)
|
||||
);
|
||||
|
||||
// Check for availability in all of the available radarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.radarrServers.filter(
|
||||
@@ -649,14 +696,7 @@ class AvailabilitySync {
|
||||
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
|
||||
const is4kMovie =
|
||||
resolution?.length === 2 && Number(resolution[0]) >= 2000;
|
||||
|
||||
if (hasSameServerInBothModes && resolution?.length === 2) {
|
||||
// Same server in both modes then use resolution to distinguish
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
} else {
|
||||
// One server type and if file exists, count it
|
||||
existsInRadarr = true;
|
||||
}
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
@@ -672,8 +712,6 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr) break;
|
||||
}
|
||||
|
||||
return existsInRadarr;
|
||||
@@ -832,50 +870,6 @@ class AvailabilitySync {
|
||||
this.plexSeasonsCache[ratingKey4k] =
|
||||
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||
}
|
||||
|
||||
if (plexMedia) {
|
||||
if (ratingKey === ratingKey4k) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
plexMedia &&
|
||||
media.mediaType === 'movie' &&
|
||||
!plexMedia.Media?.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
)
|
||||
) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
|
||||
if (plexMedia && media.mediaType === 'tv') {
|
||||
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||
if (cachedSeasons?.length) {
|
||||
let has4kInAnySeason = false;
|
||||
for (const season of cachedSeasons) {
|
||||
try {
|
||||
const episodes = await this.plexClient?.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
const has4kEpisode = episodes?.some((episode) =>
|
||||
episode.Media?.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
)
|
||||
);
|
||||
if (has4kEpisode) {
|
||||
has4kInAnySeason = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch episodes for a season, continue checking other seasons
|
||||
}
|
||||
}
|
||||
if (!has4kInAnySeason) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plexMedia) {
|
||||
@@ -999,8 +993,8 @@ class AvailabilitySync {
|
||||
existsInJellyfin = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||
existsInJellyfin = true;
|
||||
if (!ex.message.includes('404' || '500')) {
|
||||
existsInJellyfin = false;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
|
||||
@@ -2,12 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -209,8 +209,8 @@ class DiscordAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import type { EmailOptions } from 'email-templates';
|
||||
import path from 'path';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NotificationAgentGotify } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NotificationAgentNtfy } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushover } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -45,17 +45,7 @@ class PushoverAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getImagePayload(
|
||||
@@ -158,8 +148,8 @@ class PushoverAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
const url_title = url
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NotificationAgentSlack } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -183,8 +183,8 @@ class SlackAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -133,8 +133,8 @@ class TelegramAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -122,7 +122,7 @@ class WebhookAgent
|
||||
`{{${keymapKey}}}`,
|
||||
typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: (get(payload, keymapValue) ?? '')
|
||||
: get(payload, keymapValue) ?? ''
|
||||
);
|
||||
});
|
||||
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
|
||||
@@ -186,8 +186,8 @@ class WebhookAgent
|
||||
type === Notification.TEST_NOTIFICATION
|
||||
? 'test'
|
||||
: typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) || 'test';
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) || 'test';
|
||||
webhookUrl = webhookUrl.replace(
|
||||
new RegExp(`{{${keymapKey}}}`, 'g'),
|
||||
encodeURIComponent(variableValue)
|
||||
|
||||
@@ -5,7 +5,7 @@ import MediaRequest from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import webpush from 'web-push';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
@@ -24,15 +24,6 @@ interface PushNotificationPayload {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface WebPushError extends Error {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
body?: string | unknown;
|
||||
response?: {
|
||||
body?: string | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
extends BaseAgent<NotificationAgentConfig>
|
||||
implements NotificationAgent
|
||||
@@ -128,8 +119,8 @@ class WebPushAgent
|
||||
const actionUrl = payload.issue
|
||||
? `/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
const actionUrlTitle = actionUrl
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'}`
|
||||
@@ -197,30 +188,19 @@ class WebPushAgent
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
const webPushError = e as WebPushError;
|
||||
const statusCode = webPushError.statusCode || webPushError.status;
|
||||
const errorMessage = webPushError.message || String(e);
|
||||
|
||||
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||
|
||||
logger.error(
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
'Error sending web push notification; removing subscription',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -97,8 +95,6 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -115,11 +111,9 @@ class BaseScanner<T> {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
existing[is4k ? 'status4k' : 'status'] = !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.PROCESSING;
|
||||
existing[is4k ? 'status4k' : 'status'] = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
if (mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
@@ -139,21 +133,6 @@ class BaseScanner<T> {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jellyfinMediaId &&
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||
jellyfinMediaId
|
||||
) {
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||
jellyfinMediaId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (imdbId && !existing.imdbId) {
|
||||
existing.imdbId = imdbId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
@@ -194,20 +173,19 @@ class BaseScanner<T> {
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
newMedia.imdbId = imdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: !is4k && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
is4k && this.enable4kMovie && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: is4k && this.enable4kMovie && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.serviceId = !is4k ? serviceId : undefined;
|
||||
newMedia.serviceId4k = is4k ? serviceId : undefined;
|
||||
@@ -225,13 +203,6 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||
}
|
||||
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
@@ -250,12 +221,11 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
tvdbId: number,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -287,7 +257,7 @@ class BaseScanner<T> {
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -301,23 +271,6 @@ class BaseScanner<T> {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes > 0 &&
|
||||
media.jellyfinMediaId !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
@@ -327,17 +280,12 @@ class BaseScanner<T> {
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: !season.is4kOverride &&
|
||||
!season.processing &&
|
||||
season.episodes === 0 &&
|
||||
existingSeason.status === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: existingSeason.status;
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status;
|
||||
|
||||
// Same thing here, except we only do updates if 4k is enabled
|
||||
existingSeason.status4k =
|
||||
@@ -347,17 +295,12 @@ class BaseScanner<T> {
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: season.is4kOverride &&
|
||||
!season.processing &&
|
||||
season.episodes4k === 0 &&
|
||||
existingSeason.status4k === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: existingSeason.status4k;
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
@@ -366,20 +309,20 @@ class BaseScanner<T> {
|
||||
season.totalEpisodes === season.episodes && season.episodes > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow &&
|
||||
season.totalEpisodes === season.episodes4k &&
|
||||
season.episodes4k > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -486,37 +429,37 @@ class BaseScanner<T> {
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status4k === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.seasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status4k === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
} else {
|
||||
@@ -548,50 +491,34 @@ class BaseScanner<T> {
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
jellyfinMediaId: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
jellyfinMediaId4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
newSeasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${title}`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,11 +59,12 @@ searchProviders.push({
|
||||
|
||||
const successfulResponses = responses.filter(
|
||||
(r) => r.status === 'fulfilled'
|
||||
) as (
|
||||
| PromiseFulfilledResult<TmdbMovieDetails>
|
||||
| PromiseFulfilledResult<TmdbTvDetails>
|
||||
| PromiseFulfilledResult<TmdbPersonDetails>
|
||||
)[];
|
||||
) as
|
||||
| (
|
||||
| PromiseFulfilledResult<TmdbMovieDetails>
|
||||
| PromiseFulfilledResult<TmdbTvDetails>
|
||||
| PromiseFulfilledResult<TmdbPersonDetails>
|
||||
)[];
|
||||
|
||||
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
|
||||
|
||||
@@ -184,10 +185,11 @@ searchProviders.push({
|
||||
|
||||
const successfulResponses = responses.filter(
|
||||
(r) => r.status === 'fulfilled'
|
||||
) as (
|
||||
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||
)[];
|
||||
) as
|
||||
| (
|
||||
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||
)[];
|
||||
|
||||
const results: (TmdbMovieResult | TmdbTvResult)[] = [];
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
const users = await userRepository.find({
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
@@ -28,26 +30,15 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const radarrTags = await radarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = radarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
const userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await radarr.renameTag({
|
||||
id: userTag.id,
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -70,26 +61,15 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const sonarrTags = await sonarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = sonarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
const userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await sonarr.renameTag({
|
||||
id: userTag.id,
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramMessageThreadId1734786596045 implements MigrationInterface {
|
||||
export class AddTelegramMessageThreadId1734786596045
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddTelegramMessageThreadId1734786596045';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserAvatarCacheFields1743107707465 implements MigrationInterface {
|
||||
export class AddUserAvatarCacheFields1743107707465
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserAvatarCacheFields1743107707465';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_push_subscription"
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM "user_push_subscription"
|
||||
GROUP BY "endpoint", "userId"
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface {
|
||||
export class AddUserRequestDeleteCascades1608219049304
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserRequestDeleteCascades1608219049304';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface {
|
||||
export class AddLastSeasonChangeMedia1608477467935
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddLastSeasonChangeMedia1608477467935';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface {
|
||||
export class ForceDropImdbUniqueConstraint1608477467935
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ForceDropImdbUniqueConstraint1608477467936';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface {
|
||||
export class RemoveTmdbIdUniqueConstraint1609236552057
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface {
|
||||
export class AddMediaAddedFieldToMedia1610522845513
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddMediaAddedFieldToMedia1610522845513';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface {
|
||||
export class SonarrRadarrSyncServiceFields1611757511674
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SonarrRadarrSyncServiceFields1611757511674';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface {
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface {
|
||||
export class UpdateUserSettingsRegions1613955393450
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'UpdateUserSettingsRegions1613955393450';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface {
|
||||
export class AddTelegramSettingsToUserSettings1614334195680
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddTelegramSettingsToUserSettings1614334195680';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface {
|
||||
export class CreateTagsFieldonMediaRequest1617624225464
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateTagsFieldonMediaRequest1617624225464';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface {
|
||||
export class AddUserSettingsNotificationAgentsField1617730837489
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserSettingsNotificationAgentsField1617730837489';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface {
|
||||
export class CreateUserPushSubscriptions1618912653565
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateUserPushSubscriptions1618912653565';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface {
|
||||
export class AddUserSettingsNotificationTypes1619339817343
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserSettingsNotificationTypes1619339817343';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface {
|
||||
export class AddPushbulletPushoverUserSettings1635079863457
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddPushbulletPushoverUserSettings1635079863457';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWatchlistSyncUserSetting1660632269368 implements MigrationInterface {
|
||||
export class AddWatchlistSyncUserSetting1660632269368
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddWatchlistSyncUserSetting1660632269368';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaRequestIsAutoRequestedField1660714479373 implements MigrationInterface {
|
||||
export class AddMediaRequestIsAutoRequestedField1660714479373
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserSettingsStreamingRegion1727907530757 implements MigrationInterface {
|
||||
export class AddUserSettingsStreamingRegion1727907530757
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserSettingsStreamingRegion1727907530757';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramMessageThreadId1734287582736 implements MigrationInterface {
|
||||
export class AddTelegramMessageThreadId1734287582736
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddTelegramMessageThreadId1734287582736';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserAvatarCacheFields1743107645301 implements MigrationInterface {
|
||||
export class AddUserAvatarCacheFields1743107645301
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserAvatarCacheFields1743107645301';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_push_subscription"
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM "user_push_subscription"
|
||||
GROUP BY "endpoint", "userId"
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { Video } from './Movie';
|
||||
import type {
|
||||
Cast,
|
||||
Crew,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
mapVideos,
|
||||
mapWatchProviders,
|
||||
} from './common';
|
||||
import type { Video } from './Movie';
|
||||
|
||||
interface Episode {
|
||||
id: number;
|
||||
|
||||
@@ -149,7 +149,7 @@ export const mapWatchProviders = (watchProvidersResult: {
|
||||
link: provider.link,
|
||||
buy: mapWatchProviderDetails(provider.buy ?? []),
|
||||
flatrate: mapWatchProviderDetails(provider.flatrate ?? []),
|
||||
}) as WatchProviders
|
||||
} as WatchProviders)
|
||||
);
|
||||
|
||||
export const mapWatchProviderDetails = (
|
||||
@@ -162,10 +162,10 @@ export const mapWatchProviderDetails = (
|
||||
logoPath: provider.logo_path,
|
||||
id: provider.provider_id,
|
||||
name: provider.provider_name,
|
||||
}) as WatchProviderDetails
|
||||
} as WatchProviderDetails)
|
||||
);
|
||||
|
||||
const siteUrlCreator = (site: Video['site'], key: string): string =>
|
||||
({
|
||||
YouTube: `https://www.youtube.com/watch?v=${key}`,
|
||||
})[site];
|
||||
}[site]);
|
||||
|
||||
@@ -594,6 +594,189 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.initiateQuickConnect();
|
||||
|
||||
return res.status(200).json({
|
||||
code: response.Code,
|
||||
secret: response.Secret,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Jellyfin quick connect', {
|
||||
label: 'Auth',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Failed to initiate quick connect.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
|
||||
const secret = req.query.secret as string;
|
||||
|
||||
if (
|
||||
!secret ||
|
||||
typeof secret !== 'string' ||
|
||||
secret.length < 8 ||
|
||||
secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||
) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Invalid secret format',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.checkQuickConnect(secret);
|
||||
|
||||
return res.status(200).json({ authenticated: response.Authenticated });
|
||||
} catch (e) {
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: 'Failed to check Quick Connect status',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post(
|
||||
'/jellyfin/quickconnect/authenticate',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { secret?: string };
|
||||
|
||||
if (
|
||||
!body.secret ||
|
||||
typeof body.secret !== 'string' ||
|
||||
body.secret.length < 8 ||
|
||||
body.secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(body.secret)
|
||||
) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Secret required',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
|
||||
!(await userRepository.count())
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Quick Connect is not available during initial setup.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const account = await jellyfinServer.authenticateQuickConnect(
|
||||
body.secret
|
||||
);
|
||||
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
});
|
||||
|
||||
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
if (user) {
|
||||
logger.info('Quick Connect sign-in from existing user', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// Set session
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error('Quick Connect authentication failed', {
|
||||
label: 'Auth',
|
||||
error: e.message,
|
||||
ip: req.ip,
|
||||
});
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: ApiErrorCode.InvalidCredentials,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
@@ -626,6 +809,76 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
if (!user.plexId) {
|
||||
try {
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
const account = plexUsersResponse.MediaContainer.User.find(
|
||||
(account) =>
|
||||
account.$.email &&
|
||||
account.$.email.toLowerCase() === user.email.toLowerCase()
|
||||
)?.$;
|
||||
|
||||
if (
|
||||
account &&
|
||||
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
|
||||
) {
|
||||
logger.info(
|
||||
'Found matching Plex user; updating user with Plex data',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: account.id,
|
||||
plexUsername: account.username,
|
||||
}
|
||||
);
|
||||
|
||||
user.plexId = parseInt(account.id);
|
||||
user.avatar = account.thumb;
|
||||
user.email = account.email;
|
||||
user.plexUsername = account.username;
|
||||
user.userType = UserType.PLEX;
|
||||
|
||||
await userRepository.save(user);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching Plex users', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
user.plexId &&
|
||||
user.plexId !== mainUser.plexId &&
|
||||
!(await mainPlexTv.checkUserAccess(user.plexId))
|
||||
) {
|
||||
logger.warn(
|
||||
'Failed sign-in attempt from Plex user without access to the media server',
|
||||
{
|
||||
label: 'API',
|
||||
account: {
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: user.plexId,
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (user && req.session) {
|
||||
req.session.userId = user.id;
|
||||
@@ -705,7 +958,7 @@ authRoutes.post('/logout', async (req, res, next) => {
|
||||
});
|
||||
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||
}
|
||||
logger.debug('Successfully logged out user', {
|
||||
logger.info('Successfully logged out user', {
|
||||
label: 'Auth',
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -33,15 +33,15 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
user?.settings?.streamingRegion === 'all'
|
||||
? ''
|
||||
: user?.settings?.streamingRegion
|
||||
? user?.settings?.streamingRegion
|
||||
: settings.main.discoverRegion;
|
||||
? user?.settings?.streamingRegion
|
||||
: settings.main.discoverRegion;
|
||||
|
||||
const originalLanguage =
|
||||
user?.settings?.originalLanguage === 'all'
|
||||
? ''
|
||||
: user?.settings?.originalLanguage
|
||||
? user?.settings?.originalLanguage
|
||||
: settings.main.originalLanguage;
|
||||
? user?.settings?.originalLanguage
|
||||
: settings.main.originalLanguage;
|
||||
|
||||
return new TheMovieDb({
|
||||
discoverRegion,
|
||||
@@ -697,16 +697,16 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
||||
)
|
||||
)
|
||||
: isPerson(result)
|
||||
? mapPersonResult(result)
|
||||
: isCollection(result)
|
||||
? mapCollectionResult(result)
|
||||
: mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
? mapPersonResult(result)
|
||||
: isCollection(result)
|
||||
? mapCollectionResult(result)
|
||||
: mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -12,9 +12,9 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||
import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import overrideRuleRoutes from '@server/routes/overrideRule';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
import watchlistRoutes from '@server/routes/watchlist';
|
||||
|
||||
@@ -275,24 +275,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: mappedRequests,
|
||||
serviceErrors: {
|
||||
radarr: radarrServers
|
||||
.filter((s) => !s.profiles)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name:
|
||||
settings.radarr.find((r) => r.id === s.id)?.name ||
|
||||
`Radarr ${s.id}`,
|
||||
})),
|
||||
sonarr: sonarrServers
|
||||
.filter((s) => !s.profiles)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name:
|
||||
settings.sonarr.find((r) => r.id === s.id)?.name ||
|
||||
`Sonarr ${s.id}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
|
||||
@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
UserResultsResponse,
|
||||
UserWatchDataResponse,
|
||||
} from '@server/interfaces/api/userInterfaces';
|
||||
import { Permission, hasPermission } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
@@ -25,8 +25,7 @@ import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
@@ -189,82 +188,30 @@ router.post<
|
||||
}
|
||||
>('/registerPushSubscription', async (req, res, next) => {
|
||||
try {
|
||||
// This prevents race conditions where two requests both pass the checks
|
||||
await dataSource.transaction(
|
||||
async (transactionalEntityManager: EntityManager) => {
|
||||
const transactionalRepo =
|
||||
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
|
||||
// Check for existing subscription by auth or endpoint within transaction
|
||||
const existingSubscription = await transactionalRepo.findOne({
|
||||
relations: { user: true },
|
||||
where: [
|
||||
{ auth: req.body.auth, user: { id: req.user?.id } },
|
||||
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
|
||||
],
|
||||
});
|
||||
const existingSubs = await userPushSubRepository.find({
|
||||
relations: { user: true },
|
||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
|
||||
if (
|
||||
existingSubscription.endpoint === req.body.endpoint &&
|
||||
existingSubscription.auth !== req.body.auth
|
||||
) {
|
||||
existingSubscription.auth = req.body.auth;
|
||||
existingSubscription.p256dh = req.body.p256dh;
|
||||
existingSubscription.userAgent = req.body.userAgent;
|
||||
if (existingSubs.length > 0) {
|
||||
logger.debug(
|
||||
'User push subscription already exists. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
await transactionalRepo.save(existingSubscription);
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
'Updated existing push subscription with new keys for same endpoint.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'Duplicate subscription detected. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old subscriptions from the same device (userAgent) for this user
|
||||
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
|
||||
// Only clean up if we're creating a new subscription (not updating an existing one)
|
||||
if (req.body.userAgent) {
|
||||
const staleSubscriptions = await transactionalRepo.find({
|
||||
relations: { user: true },
|
||||
where: {
|
||||
userAgent: req.body.userAgent,
|
||||
user: { id: req.user?.id },
|
||||
// Only remove subscriptions with different endpoints (stale ones)
|
||||
// Keep subscriptions that might be from different browsers/tabs
|
||||
endpoint: Not(req.body.endpoint),
|
||||
},
|
||||
});
|
||||
|
||||
if (staleSubscriptions.length > 0) {
|
||||
await transactionalRepo.remove(staleSubscriptions);
|
||||
logger.debug(
|
||||
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
|
||||
{ label: 'API' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
await transactionalRepo.save(userPushSubscription);
|
||||
}
|
||||
);
|
||||
userPushSubRepository.save(userPushSubscription);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
|
||||
@@ -543,6 +543,81 @@ userSettingsRoutes.delete<{ id: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<{ secret: string }>(
|
||||
'/linked-accounts/jellyfin/quickconnect',
|
||||
isOwnProfile(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||
}
|
||||
|
||||
const secret = req.body.secret;
|
||||
if (
|
||||
!secret ||
|
||||
typeof secret !== 'string' ||
|
||||
secret.length < 8 ||
|
||||
secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid secret format' });
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||
) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(hostname);
|
||||
|
||||
try {
|
||||
const account = await jellyfinServer.authenticateQuickConnect(secret);
|
||||
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
message: 'The specified account is already linked to a Seerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
const deviceId = Buffer.from(
|
||||
`BOT_seerr_qc_link_${account.User.Id}`
|
||||
).toString('base64');
|
||||
|
||||
user.userType =
|
||||
settings.main.mediaServerType === MediaServerType.EMBY
|
||||
? UserType.EMBY
|
||||
: UserType.JELLYFIN;
|
||||
user.jellyfinUserId = account.User.Id;
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Failed to link account with Quick Connect.', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
error: e,
|
||||
});
|
||||
|
||||
return res.status(500).send();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
@EventSubscriber()
|
||||
export class IssueCommentSubscriber implements EntitySubscriberInterface<IssueComment> {
|
||||
export class IssueCommentSubscriber
|
||||
implements EntitySubscriberInterface<IssueComment>
|
||||
{
|
||||
public listenTo(): typeof IssueComment {
|
||||
return IssueComment;
|
||||
}
|
||||
|
||||
@@ -67,16 +67,16 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
: ''
|
||||
}Issue Reported`
|
||||
: type === Notification.ISSUE_RESOLVED
|
||||
? `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Resolved`
|
||||
: `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Reopened`,
|
||||
? `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Resolved`
|
||||
: `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Reopened`,
|
||||
subject: title,
|
||||
message: firstComment.message,
|
||||
issue: entity,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import Season from '@server/entity/Season';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
@@ -28,20 +27,12 @@ import type {
|
||||
RemoveEvent,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import { EventSubscriber, Not } from 'typeorm';
|
||||
|
||||
const sanitizeDisplayName = (displayName: string): string => {
|
||||
return displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRequest> {
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
{
|
||||
private async notifyAvailableMovie(
|
||||
entity: MediaRequest,
|
||||
event?: UpdateEvent<MediaRequest>
|
||||
@@ -319,15 +310,11 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
@@ -398,23 +385,10 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
try {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
if (entity.status !== MediaRequestStatus.FAILED) {
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
await requestRepository.save(entity);
|
||||
}
|
||||
} catch (saveError) {
|
||||
logger.error('Failed to mark request as FAILED', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
errorMessage:
|
||||
saveError instanceof Error
|
||||
? saveError.message
|
||||
: String(saveError),
|
||||
});
|
||||
}
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(entity);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
@@ -517,6 +491,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -584,8 +559,8 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
entity.rootFolder &&
|
||||
@@ -656,15 +631,11 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
@@ -703,6 +674,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
// We grab media again here to make sure we have the latest version of it
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -719,23 +691,10 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
try {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
if (entity.status !== MediaRequestStatus.FAILED) {
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
await requestRepository.save(entity);
|
||||
}
|
||||
} catch (saveError) {
|
||||
logger.error('Failed to mark request as FAILED', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
errorMessage:
|
||||
saveError instanceof Error
|
||||
? saveError.message
|
||||
: String(saveError),
|
||||
});
|
||||
}
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(entity);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||
@@ -783,6 +742,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
@@ -792,29 +752,26 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const statusKey = entity.is4k ? 'status4k' : 'status';
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
if (
|
||||
entity.status === MediaRequestStatus.APPROVED &&
|
||||
// Do not update the status if the item is already partially available or available
|
||||
media[statusKey] !== MediaStatus.AVAILABLE &&
|
||||
media[statusKey] !== MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
media[statusKey] !== MediaStatus.PROCESSING
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
||||
) {
|
||||
media[statusKey] = MediaStatus.PROCESSING;
|
||||
await mediaRepository.save(media);
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
if (
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
entity.status === MediaRequestStatus.DECLINED &&
|
||||
media[statusKey] !== MediaStatus.DELETED
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
) {
|
||||
media[statusKey] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -826,72 +783,14 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
if (
|
||||
media.mediaType === MediaType.TV &&
|
||||
entity.status === MediaRequestStatus.DECLINED &&
|
||||
media[statusKey] === MediaStatus.PENDING
|
||||
media.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
).length === 0 &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const pendingCount = await requestRepository.count({
|
||||
where: {
|
||||
media: { id: media.id },
|
||||
status: MediaRequestStatus.PENDING,
|
||||
is4k: entity.is4k,
|
||||
id: Not(entity.id),
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingCount === 0) {
|
||||
// Re-fetch media without requests to avoid cascade issues
|
||||
const freshMedia = await mediaRepository.findOne({
|
||||
where: { id: media.id },
|
||||
});
|
||||
if (freshMedia) {
|
||||
freshMedia[statusKey] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(freshMedia);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset season statuses when a TV request is declined
|
||||
if (
|
||||
media.mediaType === MediaType.TV &&
|
||||
entity.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
const actualSeasons = await seasonRepository.find({
|
||||
where: { media: { id: media.id } },
|
||||
});
|
||||
|
||||
for (const seasonRequest of entity.seasons) {
|
||||
seasonRequest.status = MediaRequestStatus.DECLINED;
|
||||
await seasonRequestRepository.save(seasonRequest);
|
||||
|
||||
const season = actualSeasons.find(
|
||||
(s) => s.seasonNumber === seasonRequest.seasonNumber
|
||||
);
|
||||
|
||||
if (season && season[statusKey] == MediaStatus.PENDING) {
|
||||
const otherActiveRequests = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.seasons', 'season')
|
||||
.where('request.mediaId = :mediaId', { mediaId: media.id })
|
||||
.andWhere('request.id != :requestId', { requestId: entity.id })
|
||||
.andWhere('request.is4k = :is4k', { is4k: entity.is4k })
|
||||
.andWhere('request.status NOT IN (:...statuses)', {
|
||||
statuses: [
|
||||
MediaRequestStatus.DECLINED,
|
||||
MediaRequestStatus.COMPLETED,
|
||||
],
|
||||
})
|
||||
.andWhere('season.seasonNumber = :seasonNumber', {
|
||||
seasonNumber: season.seasonNumber,
|
||||
})
|
||||
.getCount();
|
||||
|
||||
if (otherActiveRequests === 0) {
|
||||
season[statusKey] = MediaStatus.UNKNOWN;
|
||||
await seasonRepository.save(season);
|
||||
}
|
||||
}
|
||||
}
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
// Approve child seasons if parent is approved
|
||||
@@ -915,74 +814,54 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
const needsStatusUpdate =
|
||||
if (!fullMedia) return;
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||
fullMedia.status !== MediaStatus.AVAILABLE;
|
||||
fullMedia.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
const needs4kStatusUpdate =
|
||||
if (
|
||||
!fullMedia.requests.some((request) => request.is4k) &&
|
||||
fullMedia.status4k !== MediaStatus.AVAILABLE;
|
||||
|
||||
if (needsStatusUpdate || needs4kStatusUpdate) {
|
||||
// Re-fetch WITHOUT requests to avoid cascade issues on save
|
||||
const cleanMedia = await manager.findOneOrFail(Media, {
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
|
||||
if (needsStatusUpdate) {
|
||||
cleanMedia.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
if (needs4kStatusUpdate) {
|
||||
cleanMedia.status4k = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
await manager.save(cleanMedia);
|
||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
await manager.save(fullMedia);
|
||||
}
|
||||
|
||||
public async afterUpdate(event: UpdateEvent<MediaRequest>): Promise<void> {
|
||||
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendToRadarr(event.entity as MediaRequest);
|
||||
await this.sendToSonarr(event.entity as MediaRequest);
|
||||
await this.updateParentStatus(event.entity as MediaRequest);
|
||||
this.sendToRadarr(event.entity as MediaRequest);
|
||||
this.sendToSonarr(event.entity as MediaRequest);
|
||||
|
||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||
await this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
await this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||
}
|
||||
this.updateParentStatus(event.entity as MediaRequest);
|
||||
|
||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error in afterUpdate subscriber', {
|
||||
label: 'Media Request',
|
||||
requestId: (event.entity as MediaRequest).id,
|
||||
errorMessage: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async afterInsert(event: InsertEvent<MediaRequest>): Promise<void> {
|
||||
public afterInsert(event: InsertEvent<MediaRequest>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendToRadarr(event.entity as MediaRequest);
|
||||
await this.sendToSonarr(event.entity as MediaRequest);
|
||||
await this.updateParentStatus(event.entity as MediaRequest);
|
||||
} catch (e) {
|
||||
logger.error('Error in afterInsert subscriber', {
|
||||
label: 'Media Request',
|
||||
requestId: (event.entity as MediaRequest).id,
|
||||
errorMessage: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
this.sendToRadarr(event.entity as MediaRequest);
|
||||
this.sendToSonarr(event.entity as MediaRequest);
|
||||
|
||||
this.updateParentStatus(event.entity as MediaRequest);
|
||||
}
|
||||
|
||||
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user