Compare commits
34 Commits
0xsysr3ll/
...
preview-ty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
801c95bbc5 | ||
|
|
8b41685b31 | ||
|
|
5bd31040c0 | ||
|
|
127a91ca9c | ||
|
|
7d2e24a528 | ||
|
|
ddf347994a | ||
|
|
0f7d29624b | ||
|
|
f627a8e9db | ||
|
|
6031fab3b4 | ||
|
|
e1d3f29383 | ||
|
|
f8f90cb903 | ||
|
|
65844a2f23 | ||
|
|
62755692e9 | ||
|
|
beba2ea099 | ||
|
|
88b2e7843f | ||
|
|
dbd5935ade | ||
|
|
bb2120c14d | ||
|
|
c9037f77e6 | ||
|
|
48631db989 | ||
|
|
ac7c2983d3 | ||
|
|
767dc529e8 | ||
|
|
448a25e2a4 | ||
|
|
3f35b8c886 | ||
|
|
d0f029b46e | ||
|
|
e0a81038cd | ||
|
|
4ab919360a | ||
|
|
adbcf80333 | ||
|
|
f91a26befe | ||
|
|
0c95b5ec91 | ||
|
|
193d4dc668 | ||
|
|
d0c9afc16e | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 |
@@ -16,6 +16,7 @@ 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,6 +91,14 @@ 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,6 +27,14 @@ 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:
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
container: node:22.20.0-alpine3.22@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af
|
||||
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run: pnpm exec cypress install
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2
|
||||
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Lychee link checker
|
||||
uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
|
||||
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Derive preview version from tag
|
||||
id: ver
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- 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@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate changelog
|
||||
id: git-cliff
|
||||
uses: orhun/git-cliff-action@d77b37db2e3f7398432d34b72a12aa3e2ba87e51 # v4.6.0
|
||||
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
|
||||
with:
|
||||
config: .github/cliff.toml
|
||||
args: -vv --current
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
needs: changelog
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- 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@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
COSIGN_YES: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Install Trivy
|
||||
uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 # v0.2.4
|
||||
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
|
||||
|
||||
- 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: 2138788
|
||||
private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.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@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
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@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: trivy.sarif
|
||||
|
||||
@@ -4,15 +4,17 @@ dist/
|
||||
config/
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
.github
|
||||
.vscode
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
public/
|
||||
!public/sw.js
|
||||
docs/
|
||||
!/public/
|
||||
/public/*
|
||||
!/public/sw.js
|
||||
public/*
|
||||
!public/sw.js
|
||||
|
||||
# helm charts
|
||||
**/charts
|
||||
|
||||
# Prettier breaks GitHub alert syntax in markdown
|
||||
*.md
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [require('./merged-prettier-plugin.js')],
|
||||
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
overrides: [
|
||||
@@ -27,5 +27,11 @@ module.exports = {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'public/offline.html',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7 AS base
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 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.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG COMMIT_TAG
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
20
README.md
20
README.md
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
|
||||
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">
|
||||
|
||||
@@ -22,17 +22,6 @@ This is typically not needed. Please refer to your webhook provider's documentat
|
||||
|
||||
This value will be sent as an `Authorization` HTTP header.
|
||||
|
||||
### Custom Headers (optional)
|
||||
|
||||
You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires.
|
||||
|
||||
- Click "Add Header" to add a new header
|
||||
- Enter the header name and value
|
||||
|
||||
:::warning
|
||||
You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method.
|
||||
:::
|
||||
|
||||
### JSON Payload
|
||||
|
||||
Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/* 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,7 +5,6 @@
|
||||
"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",
|
||||
@@ -17,7 +16,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 --loglevel warn --write --cache .",
|
||||
"format": "prettier --log-level warn --write --cache .",
|
||||
"format:check": "prettier --check --cache .",
|
||||
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
|
||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||
@@ -38,18 +37,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.2",
|
||||
"axios": "1.13.3",
|
||||
"axios-rate-limit": "1.4.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"bowser": "2.13.1",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.7",
|
||||
@@ -68,16 +67,15 @@
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"mime": "3",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.35",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.16.3",
|
||||
"plex-api": "5.3.2",
|
||||
"nodemailer": "7.0.12",
|
||||
"openpgp": "6.3.0",
|
||||
"pg": "8.17.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
@@ -90,7 +88,6 @@
|
||||
"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",
|
||||
@@ -101,28 +98,28 @@
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.3.7",
|
||||
"swr": "2.3.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.12",
|
||||
"typeorm": "0.3.28",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"undici": "^7.16.0",
|
||||
"undici": "^7.18.2",
|
||||
"validator": "^13.15.23",
|
||||
"web-push": "3.6.7",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.18.3",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"xml2js": "0.5.0",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.24.2"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"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": "5.0.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/country-flag-icons": "1.2.2",
|
||||
"@types/csurf": "1.11.5",
|
||||
@@ -133,7 +130,7 @@
|
||||
"@types/mime": "3",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/nodemailer": "7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
@@ -142,20 +139,20 @@
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.22",
|
||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||
"@typescript-eslint/parser": "7.18.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "14.1.0",
|
||||
"cypress": "14.5.4",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
@@ -166,24 +163,20 @@
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "3.1.11",
|
||||
"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",
|
||||
"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",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "4.9.5"
|
||||
"typescript": "5.4.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"
|
||||
@@ -210,6 +203,10 @@
|
||||
"cypress",
|
||||
"sharp",
|
||||
"sqlite3"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"sqlite3>node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.18.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6206
pnpm-lock.yaml
generated
6206
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
/* 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' });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
export interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
rateLimit?: {
|
||||
maxRPS: number;
|
||||
maxRequests: number;
|
||||
@@ -32,6 +33,7 @@ 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;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -420,7 +420,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,7 +1,14 @@
|
||||
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';
|
||||
import NodePlexAPI from 'plex-api';
|
||||
|
||||
interface PlexStatusResponse {
|
||||
MediaContainer: {
|
||||
machineIdentifier: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -84,9 +91,7 @@ interface PlexMetadataResponse {
|
||||
};
|
||||
}
|
||||
|
||||
class PlexAPI {
|
||||
private plexClient: NodePlexAPI;
|
||||
|
||||
class PlexAPI extends ExternalAPI {
|
||||
constructor({
|
||||
plexToken,
|
||||
plexSettings,
|
||||
@@ -97,48 +102,33 @@ class PlexAPI {
|
||||
timeout?: number;
|
||||
}) {
|
||||
const settings = getSettings();
|
||||
let settingsPlex: PlexSettings | undefined;
|
||||
plexSettings
|
||||
? (settingsPlex = plexSettings)
|
||||
: (settingsPlex = getSettings().plex);
|
||||
const settingsPlex = plexSettings ?? settings.plex;
|
||||
|
||||
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);
|
||||
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',
|
||||
},
|
||||
},
|
||||
// requestOptions: {
|
||||
// includeChildren: 1,
|
||||
// },
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Seerr',
|
||||
deviceName: 'Seerr',
|
||||
platform: 'Seerr',
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getStatus() {
|
||||
return await this.plexClient.query('/');
|
||||
public async getStatus(): Promise<PlexStatusResponse> {
|
||||
return await this.get('/');
|
||||
}
|
||||
|
||||
public async getLibraries(): Promise<PlexLibrary[]> {
|
||||
const response = await this.plexClient.query<PlexLibrariesResponse>(
|
||||
'/library/sections'
|
||||
);
|
||||
const response = await this.get<PlexLibrariesResponse>('/library/sections');
|
||||
|
||||
return response.MediaContainer.Directory;
|
||||
}
|
||||
@@ -187,13 +177,15 @@ class PlexAPI {
|
||||
id: string,
|
||||
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
|
||||
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?includeGuids=1`,
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Container-Start': `${offset}`,
|
||||
'X-Plex-Container-Size': `${size}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
totalSize: response.MediaContainer.totalSize,
|
||||
@@ -205,7 +197,7 @@ class PlexAPI {
|
||||
key: string,
|
||||
options: { includeChildren?: boolean } = {}
|
||||
): Promise<PlexMetadata> {
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
const response = await this.get<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}${
|
||||
options.includeChildren ? '?includeChildren=1' : ''
|
||||
}`
|
||||
@@ -215,7 +207,7 @@ class PlexAPI {
|
||||
}
|
||||
|
||||
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
const response = await this.get<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}/children`
|
||||
);
|
||||
|
||||
@@ -229,15 +221,17 @@ class PlexAPI {
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
},
|
||||
});
|
||||
{
|
||||
headers: {
|
||||
'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',
|
||||
|
||||
@@ -209,6 +209,34 @@ 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);
|
||||
}
|
||||
@@ -318,6 +346,38 @@ 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,8 +392,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AfterUpdate,
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -332,6 +333,12 @@ export class MediaRequest {
|
||||
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||
await mediaRepository.save(media);
|
||||
|
||||
if (!media.id) {
|
||||
throw new Error(
|
||||
`Failed to save media before creating request. Media type: ${requestBody.mediaType}, TMDB ID: ${requestBody.mediaId}, persisted media id: ${media.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
media,
|
||||
@@ -442,6 +449,12 @@ export class MediaRequest {
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
if (!media.id) {
|
||||
throw new Error(
|
||||
`Failed to save media before creating request. Media type: TV, TMDB ID: ${requestBody.mediaId}, is4k: ${requestBody.is4k}`
|
||||
);
|
||||
}
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
media,
|
||||
@@ -519,6 +532,7 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'mediaId' })
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
|
||||
@@ -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 { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { Permission, hasPermission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { AfterDate } from '@server/utils/dateHelpers';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { AfterDate } from '@server/utils/dateHelpers';
|
||||
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,8 +1,15 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -97,7 +97,10 @@ app
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
await createCustomProxyAgent(
|
||||
settings.network.proxy,
|
||||
settings.network.forceIpv4First
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
|
||||
@@ -300,7 +300,6 @@ 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) =>
|
||||
@@ -311,48 +310,7 @@ 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) =>
|
||||
@@ -363,44 +321,32 @@ class AvailabilitySync {
|
||||
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
// 4k
|
||||
const finalSeasons4k: Map<number, boolean> = new Map();
|
||||
let finalSeasons: Map<number, boolean>;
|
||||
let finalSeasons4k: Map<number, boolean>;
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -567,8 +513,8 @@ class AvailabilitySync {
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -642,8 +588,8 @@ class AvailabilitySync {
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -666,6 +612,13 @@ 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(
|
||||
@@ -696,7 +649,14 @@ class AvailabilitySync {
|
||||
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
|
||||
const is4kMovie =
|
||||
resolution?.length === 2 && Number(resolution[0]) >= 2000;
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
@@ -712,6 +672,8 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr) break;
|
||||
}
|
||||
|
||||
return existsInRadarr;
|
||||
@@ -870,6 +832,50 @@ 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) {
|
||||
@@ -993,8 +999,8 @@ class AvailabilitySync {
|
||||
existsInJellyfin = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404' || '500')) {
|
||||
existsInJellyfin = false;
|
||||
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||
existsInJellyfin = true;
|
||||
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 { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
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 { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } 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 { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } 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 { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } 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 { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
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 { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -45,7 +45,17 @@ class PushoverAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
return true;
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getImagePayload(
|
||||
@@ -148,8 +158,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 { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } 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 { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
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 { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } 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)
|
||||
@@ -196,33 +196,16 @@ class WebhookAgent
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (settings.options.authHeader) {
|
||||
headers.Authorization = settings.options.authHeader;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.options.customHeaders &&
|
||||
settings.options.customHeaders.length > 0
|
||||
) {
|
||||
settings.options.customHeaders.forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
// Don't override Authorization header if it's already set via authHeader
|
||||
if (
|
||||
header.key.toLowerCase() !== 'authorization' ||
|
||||
!settings.options.authHeader
|
||||
) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await axios.post(
|
||||
webhookUrl,
|
||||
this.buildPayload(type, payload),
|
||||
Object.keys(headers).length > 0 ? { headers } : undefined
|
||||
settings.options.authHeader
|
||||
? {
|
||||
headers: {
|
||||
Authorization: settings.options.authHeader,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -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 { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import webpush from 'web-push';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
@@ -24,6 +24,15 @@ 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
|
||||
@@ -119,8 +128,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'}`
|
||||
@@ -188,19 +197,30 @@ 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(
|
||||
'Error sending web push notification; removing subscription',
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
}
|
||||
);
|
||||
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -111,9 +115,11 @@ class BaseScanner<T> {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
existing[is4k ? 'status4k' : 'status'] = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
existing[is4k ? 'status4k' : 'status'] = !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.PROCESSING;
|
||||
if (mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
@@ -133,6 +139,21 @@ 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
|
||||
@@ -173,19 +194,20 @@ 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;
|
||||
@@ -203,6 +225,13 @@ 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}`);
|
||||
}
|
||||
@@ -221,11 +250,12 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -257,7 +287,7 @@ class BaseScanner<T> {
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -271,6 +301,23 @@ 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
|
||||
@@ -280,12 +327,17 @@ 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
|
||||
: existingSeason.status;
|
||||
? 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;
|
||||
|
||||
// Same thing here, except we only do updates if 4k is enabled
|
||||
existingSeason.status4k =
|
||||
@@ -295,12 +347,17 @@ 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
|
||||
: existingSeason.status4k;
|
||||
? 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;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
@@ -309,20 +366,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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -429,37 +486,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 {
|
||||
@@ -491,34 +548,50 @@ 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,12 +59,11 @@ 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)[] = [];
|
||||
|
||||
@@ -185,11 +184,10 @@ searchProviders.push({
|
||||
|
||||
const successfulResponses = responses.filter(
|
||||
(r) => r.status === 'fulfilled'
|
||||
) as
|
||||
| (
|
||||
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||
)[];
|
||||
) as (
|
||||
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||
)[];
|
||||
|
||||
const results: (TmdbMovieResult | TmdbTvResult)[] = [];
|
||||
|
||||
|
||||
@@ -275,7 +275,6 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
|
||||
webhookUrl: string;
|
||||
jsonPayload: string;
|
||||
authHeader?: string;
|
||||
customHeaders?: { key: string; value: string }[];
|
||||
supportVariables?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find({
|
||||
select: ['id'],
|
||||
});
|
||||
const users = await userRepository.find();
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
@@ -30,15 +28,26 @@ 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 + ' - ')
|
||||
const userTag = radarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await radarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -61,15 +70,26 @@ 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 + ' - ')
|
||||
const userTag = sonarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await sonarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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> {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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,8 +1,6 @@
|
||||
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> {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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,6 +7,7 @@ import type {
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { Video } from './Movie';
|
||||
import type {
|
||||
Cast,
|
||||
Crew,
|
||||
@@ -24,7 +25,6 @@ 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];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -279,7 +279,6 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
||||
'utf8'
|
||||
)
|
||||
),
|
||||
customHeaders: webhookSettings.options.customHeaders ?? [],
|
||||
supportVariables: webhookSettings.options.supportVariables ?? false,
|
||||
},
|
||||
};
|
||||
@@ -302,7 +301,6 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
customHeaders: req.body.options.customHeaders ?? [],
|
||||
supportVariables: req.body.options.supportVariables ?? false,
|
||||
},
|
||||
};
|
||||
@@ -335,7 +333,6 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
customHeaders: req.body.options.customHeaders ?? [],
|
||||
supportVariables: req.body.options.supportVariables ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 { getRepository } from '@server/datasource';
|
||||
import dataSource, { 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 { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { Permission, hasPermission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import { In } from 'typeorm';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, Not } from 'typeorm';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
@@ -188,30 +189,82 @@ router.post<
|
||||
}
|
||||
>('/registerPushSubscription', async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
// This prevents race conditions where two requests both pass the checks
|
||||
await dataSource.transaction(
|
||||
async (transactionalEntityManager: EntityManager) => {
|
||||
const transactionalRepo =
|
||||
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||
|
||||
const existingSubs = await userPushSubRepository.find({
|
||||
relations: { user: true },
|
||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||
});
|
||||
// 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 } },
|
||||
],
|
||||
});
|
||||
|
||||
if (existingSubs.length > 0) {
|
||||
logger.debug(
|
||||
'User push subscription already exists. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return res.status(204).send();
|
||||
}
|
||||
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;
|
||||
|
||||
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(existingSubscription);
|
||||
|
||||
userPushSubRepository.save(userPushSubscription);
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
|
||||
@@ -13,9 +13,7 @@ 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,
|
||||
|
||||
@@ -29,10 +29,18 @@ import type {
|
||||
} from 'typeorm';
|
||||
import { EventSubscriber } 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, '');
|
||||
};
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
{
|
||||
export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRequest> {
|
||||
private async notifyAvailableMovie(
|
||||
entity: MediaRequest,
|
||||
event?: UpdateEvent<MediaRequest>
|
||||
@@ -310,11 +318,15 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
@@ -559,8 +571,8 @@ export class MediaRequestSubscriber
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
entity.rootFolder &&
|
||||
@@ -631,11 +643,15 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ApiErrorCode } from '@server/constants/error';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public statusCode: number, public errorCode: ApiErrorCode) {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
public errorCode: ApiErrorCode
|
||||
) {
|
||||
super();
|
||||
|
||||
this.name = 'apiError';
|
||||
|
||||
33
server/types/plex-api.d.ts
vendored
33
server/types/plex-api.d.ts
vendored
@@ -1,33 +0,0 @@
|
||||
declare module 'plex-api' {
|
||||
export default class PlexAPI {
|
||||
constructor(intiialOptions: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
timeout?: number;
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi: PlexAPI,
|
||||
cb: (err?: string, token?: string) => void
|
||||
) => void;
|
||||
};
|
||||
options: {
|
||||
identifier: string;
|
||||
product: string;
|
||||
deviceName: string;
|
||||
platform: string;
|
||||
};
|
||||
requestOptions?: Record<string, string | number>;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
query: <T extends Record<string, any>>(
|
||||
endpoint:
|
||||
| string
|
||||
| {
|
||||
uri: string;
|
||||
extraHeaders?: Record<string, string | number>;
|
||||
}
|
||||
) => Promise<T>;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,14 @@ export let requestInterceptorFunction: (
|
||||
) => InternalAxiosRequestConfig;
|
||||
|
||||
export default async function createCustomProxyAgent(
|
||||
proxySettings: ProxySettings
|
||||
proxySettings: ProxySettings,
|
||||
forceIpv4First?: boolean
|
||||
) {
|
||||
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
||||
const defaultAgent = new Agent({
|
||||
keepAliveTimeout: 5000,
|
||||
connections: 50,
|
||||
connect: forceIpv4First ? { family: 4 } : undefined,
|
||||
});
|
||||
|
||||
const skipUrl = (url: string | URL) => {
|
||||
const hostname =
|
||||
@@ -67,16 +72,23 @@ export default async function createCustomProxyAgent(
|
||||
uri: proxyUrl,
|
||||
token,
|
||||
keepAliveTimeout: 5000,
|
||||
connections: 50,
|
||||
connect: forceIpv4First ? { family: 4 } : undefined,
|
||||
});
|
||||
|
||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
|
||||
const agentOptions = {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
keepAlive: true,
|
||||
maxSockets: 50,
|
||||
maxFreeSockets: 10,
|
||||
timeout: 5000,
|
||||
scheduling: 'lifo' as const,
|
||||
family: forceIpv4First ? 4 : undefined,
|
||||
};
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, agentOptions);
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, agentOptions);
|
||||
|
||||
requestInterceptorFunction = (config) => {
|
||||
const url = config.baseURL
|
||||
|
||||
@@ -385,7 +385,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="z-10 ml-4 mt-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">Status</span>
|
||||
<Badge badgeType="danger">
|
||||
|
||||
@@ -329,7 +329,7 @@ const BlacklistedTagImportForm = forwardRef<
|
||||
const VerifyClearIndicator = <
|
||||
Option,
|
||||
IsMuti extends boolean,
|
||||
Group extends GroupBase<Option>
|
||||
Group extends GroupBase<Option>,
|
||||
>(
|
||||
props: ClearIndicatorProps<Option, IsMuti, Group>
|
||||
) => {
|
||||
|
||||
@@ -134,7 +134,10 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
type: 'or',
|
||||
}) &&
|
||||
data.parts.filter(
|
||||
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo.status === MediaStatus.DELETED ||
|
||||
part.mediaInfo.status === MediaStatus.UNKNOWN
|
||||
).length > 0;
|
||||
|
||||
const hasRequestable4k =
|
||||
@@ -144,7 +147,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
}) &&
|
||||
data.parts.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo.status4k === MediaStatus.DELETED ||
|
||||
part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||
).length > 0;
|
||||
|
||||
const collectionAttributes: React.ReactNode[] = [];
|
||||
|
||||
@@ -13,7 +13,7 @@ export type ButtonType =
|
||||
// Helper type to override types (overrides onClick)
|
||||
type MergeElementProps<
|
||||
T extends React.ElementType,
|
||||
P extends Record<string, unknown>
|
||||
P extends Record<string, unknown>,
|
||||
> = Omit<React.ComponentProps<T>, keyof P> & P;
|
||||
|
||||
type ElementTypes = 'button' | 'a';
|
||||
|
||||
@@ -57,7 +57,7 @@ const DropdownItems = ({
|
||||
>
|
||||
<Menu.Items
|
||||
className={[
|
||||
'absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md p-1 shadow-lg',
|
||||
'absolute right-0 z-40 -mr-1 mt-2 w-56 origin-top-right rounded-md p-1 shadow-lg',
|
||||
dropdownType === 'ghost'
|
||||
? 'border border-gray-700 bg-gray-800 bg-opacity-80 backdrop-blur'
|
||||
: 'bg-indigo-600',
|
||||
|
||||
@@ -91,7 +91,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
<Transition.Child
|
||||
appear
|
||||
as="div"
|
||||
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
|
||||
className="fixed bottom-0 left-0 right-0 top-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
@@ -116,7 +116,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
|
||||
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pb-4 pt-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -135,7 +135,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
ref={modalRef}
|
||||
>
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
|
||||
<div className="absolute left-0 right-0 top-0 z-0 h-64 max-h-full w-full">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
|
||||
@@ -83,7 +83,7 @@ const MultiRangeSlider = ({
|
||||
max={max}
|
||||
value={valueMax}
|
||||
step="1"
|
||||
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
|
||||
className={`pointer-events-none absolute left-0 right-0 top-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
|
||||
isHidden
|
||||
? 'password'
|
||||
: props.type !== 'password'
|
||||
? props.type ?? 'text'
|
||||
: 'text'
|
||||
? (props.type ?? 'text')
|
||||
: 'text'
|
||||
}
|
||||
/>
|
||||
<button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user