Compare commits

..

4 Commits

Author SHA1 Message Date
fallenbagel
8fa68da481 refactor(serviceavailabilitychecker): add missing closing parenthesis in debug log 2025-12-30 05:01:03 +08:00
fallenbagel
cf5a85ba0b refactor(serviceavailabilitychecker): correct set initialization for season numbers 2025-12-30 04:59:14 +08:00
fallenbagel
9cbd5f4260 refactor(jellyfin-scanner): correct variable name for 4k availability checks 2025-12-30 04:57:08 +08:00
fallenbagel
09233a32b3 fix(jellyfin-scanner): use service instance for 4k availability detection
when 4k services are enabled, jellyfin scanner will now check which arr instance has the file
todetermine availability tier instead of relying solely on resolution detection. This should fix
theincorrecta availability status when a 4k request results in a lower resolution file. Fallbacks
tooriginal resolution based detection when media not found.

fix #1744
2025-12-29 23:29:27 +08:00
250 changed files with 6754 additions and 21959 deletions

View File

@@ -16,7 +16,6 @@ module.exports = {
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'warn', // disable the rule for now to replicate previous behavior
'@typescript-eslint/camelcase': 0,
'@typescript-eslint/no-use-before-define': 0,
'jsx-a11y/no-noninteractive-tabindex': 0,

View File

@@ -22,77 +22,14 @@ concurrency:
cancel-in-progress: true
jobs:
i18n:
name: i18n Check
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Pnpm Setup
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: 'package.json'
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
env:
CI: true
run: pnpm install
- name: i18n Check
shell: bash
env:
BODY: |
The i18n check failed because translation messages are out of sync.
This usually happens when you've added or modified translation strings in your code but haven't updated the translation file.
Please run `pnpm i18n:extract` and commit the changes.
run: |
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
node bin/check-i18n.js
check_failed=$?
if [ $check_failed -eq 1 ]; then
retry gh pr edit "$NUMBER" -R "$GH_REPO" --add-label "i18n-out-of-sync" || true
retry gh pr comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
else
retry gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "i18n-out-of-sync" || true
fi
exit $check_failed
test:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
container: node:22.20.0-alpine3.22@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -141,7 +78,7 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -150,7 +87,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Warm cache (no push) — ${{ matrix.platform }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
@@ -177,7 +114,7 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -186,7 +123,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -203,7 +140,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ env.DOCKER_HUB }}

View File

@@ -37,20 +37,20 @@ jobs:
language: [actions, javascript]
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
category: '/language:${{ matrix.language }}'

View File

@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -67,7 +67,7 @@ jobs:
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2
with:
install: false
build: pnpm cypress:build

View File

@@ -23,7 +23,7 @@ jobs:
name: Build Docusaurus
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -36,13 +36,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Run Lychee link checker
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
with:
fail: false
args: >-

View File

@@ -28,7 +28,7 @@ jobs:
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -151,7 +151,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -28,7 +28,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -37,7 +37,7 @@ jobs:
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
- name: Set up chart-testing
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Ensure documentation is updated
uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c

View File

@@ -33,7 +33,7 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -42,7 +42,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Derive preview version from tag
id: ver
@@ -79,7 +79,7 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -88,7 +88,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -115,7 +115,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ env.DOCKER_HUB }}

View File

@@ -27,14 +27,14 @@ jobs:
release_body: ${{ steps.git-cliff.outputs.content }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Generate changelog
id: git-cliff
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
uses: orhun/git-cliff-action@d77b37db2e3f7398432d34b72a12aa3e2ba87e51 # v4.6.0
with:
config: .github/cliff.toml
args: -vv --current
@@ -50,7 +50,7 @@ jobs:
needs: changelog
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -76,7 +76,7 @@ jobs:
VERSION: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -85,7 +85,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Warm cache [${{ matrix.platform }}]
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
@@ -115,7 +115,7 @@ jobs:
VERSION: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -124,7 +124,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -141,7 +141,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ env.DOCKER_HUB }}
@@ -201,7 +201,7 @@ jobs:
COSIGN_YES: 'true'
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -209,7 +209,7 @@ jobs:
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Install Trivy
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 # v0.2.4
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -25,19 +25,19 @@ jobs:
if: github.actor == 'renovate[bot]'
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: 2138788
private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
- name: Set up chart-testing
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Run chart-testing (list-changed)
id: list-changed

View File

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

View File

@@ -24,7 +24,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -56,6 +56,6 @@ jobs:
ignore-unfixed: true
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/upload-sarif@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
sarif_file: trivy.sarif

View File

@@ -4,17 +4,15 @@ dist/
config/
pnpm-lock.yaml
cypress/config/settings.cypress.json
.github
.vscode
# assets
src/assets/
docs/
public/*
public/
!public/sw.js
docs/
!/public/
/public/*
!/public/sw.js
# helm charts
**/charts
# Prettier breaks GitHub alert syntax in markdown
*.md

View File

@@ -1,5 +1,5 @@
module.exports = {
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
plugins: [require('./merged-prettier-plugin.js')],
singleQuote: true,
trailingComma: 'es5',
overrides: [
@@ -27,11 +27,5 @@ module.exports = {
rangeEnd: 0,
},
},
{
files: 'public/offline.html',
options: {
rangeEnd: 0,
},
},
],
};

View File

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

View File

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

View File

@@ -32,28 +32,10 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
## Getting Started
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
Check out our documentation for instructions on how to install and run Seerr:
https://docs.seerr.dev/getting-started/
> [!IMPORTANT]
> **Seerr is not officially released yet.**
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
The documentation linked above is for running the **latest Jellyseerr** release.
> [!WARNING]
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
Instead, follow the dedicated migration guide (with `:develop` tag):
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
> [!CAUTION]
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
## Preview
<img src="./public/preview.jpg">

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env node
/**
* Check that i18n locale files are in sync with extracted messages.
* Runs `pnpm i18n:extract` and compares en.json; exits 1 if they differ.
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const localePath = path.join(
__dirname,
'..',
'src',
'i18n',
'locale',
'en.json'
);
const backupPath = `${localePath}.bak`;
try {
fs.copyFileSync(localePath, backupPath);
execSync('pnpm i18n:extract', { stdio: 'inherit' });
const original = fs.readFileSync(backupPath, 'utf8');
const extracted = fs.readFileSync(localePath, 'utf8');
fs.unlinkSync(backupPath);
if (original !== extracted) {
console.error(
"i18n messages are out of sync. Please run 'pnpm i18n:extract' and commit the changes."
);
process.exit(1);
}
} catch (err) {
if (fs.existsSync(backupPath)) {
fs.unlinkSync(backupPath);
}
throw err;
}

View File

@@ -38,13 +38,6 @@ For details on the Docker CLI, please [review the official `docker run` document
#### Installation:
```bash
# Create the appdata folder
mkdir /path/to/appdata/config
# Chown the folder as the container runs as the `node` user (UID 1000).
chown -R 1000:1000 /path/to/appdata/config
```
```bash
docker run -d \
--name seerr \
@@ -55,16 +48,20 @@ docker run -d \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
ghcr.io/seerr-team/seerr:latest
```
The argument `-e PORT=5055` is optional.
If you want to add a healthcheck to the above command, you can add the following flags :
```
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
--health-start-period 20s \
--health-timeout 3s \
--health-interval 15s \
--health-retries 3 \
ghcr.io/seerr-team/seerr:latest
```
The argument `-e PORT=5055` is optional.
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
#### Updating:
@@ -118,13 +115,6 @@ services:
restart: unless-stopped
```
```bash
# Create the appdata folder
mkdir /path/to/appdata/config
# Chown the folder as the container runs as the `node` user (UID 1000).
chown -R 1000:1000 /path/to/appdata/config
```
Then, start all services defined in the Compose file:
```bash
docker compose up -d
@@ -176,16 +166,20 @@ docker run -d \
-p 5055:5055 \
-v seerr-data:/app/config \
--restart unless-stopped \
ghcr.io/seerr-team/seerr:latest
```
The argument `-e PORT=5055` is optional.
If you want to add a healthcheck to the above command, you can add the following flags :
```
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
--health-start-period 20s \
--health-timeout 3s \
--health-interval 15s \
--health-retries 3 \
ghcr.io/seerr-team/seerr:latest
```
The argument `-e PORT=5055` is optional.
#### Updating:
Pull the latest image:
```bash

View File

@@ -7,9 +7,5 @@ import DocCardList from '@theme/DocCardList';
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
:::info
Want to add a third-party installation method? Contributions are welcome! Feel free to open a pull request.
:::
<DocCardList />

View File

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

View File

@@ -1,20 +0,0 @@
---
title: TrueNAS (Advanced)
description: Install Seerr using TrueNAS
sidebar_position: 4
---
# TrueNAS
:::danger
This method has not yet been updated for Seerr and is currently a work in progress.
You can follow the ongoing work on this issue https://github.com/truenas/apps/issues/3374.
:::
<!--
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
:::warning
This method is not recommended for most users. It is intended for advanced users who are using TrueNAS distribution.
:::
-->

View File

@@ -5,12 +5,6 @@ sidebar_position: 3
---
# Unraid
:::danger
This method has not yet been updated for Seerr and is awaiting a community contribution.
Feel free to open a pull request on GitHub to update this installation method.
:::
<!--
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
@@ -24,4 +18,3 @@ This method is not recommended for most users. It is intended for advanced users
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
5. Click apply and access "Seerr" at your `<ServerIP:HostPort>` in a web browser.
-->

View File

@@ -9,20 +9,11 @@ Whether you come from Overseerr or Jellyseerr, you don't need to perform any man
This migration will run automatically the first time you start your instance using the Seerr codebase (Docker image or source build or Kubernetes, etc.).
An additional migration will happen for Overseerr users, to migrate their configuration to the new codebase.
:::danger
:::warning
Before doing anything you should backup your existing instance so that you can rollback in case something goes wrong.
See [Backups](/using-seerr/backups) for details on how to properly backup your instance.
:::
:::warning
Installation methods are now divided into two categories: official and third-party methods.
The Seerr team is only responsible for official installation methods, while third-party methods are maintained by the community.
Some methods are currently not maintained, but this does not mean they are permanently discontinued. The community may restore and support them if they choose to do so.
- **Unraid app:** Not maintained
- **Snap package:** Not maintained
:::
## Docker
Refer to [Seerr Docker Documentation](/getting-started/docker), all of our examples have been updated to reflect the below change.
@@ -32,9 +23,8 @@ Changes :
- The container can now be run as a non-root user (`node` user); remove the `user` directive if you have configured it.
- The container no longer provides an init process, so you must configure it by adding `init: true` for Docker Compose or `--init` for the Docker CLI.
#### Config folder permissions
:::info
Since the container now runs as the `node` user (UID 1000), you must ensure your config folder has the correct permissions. The `node` user must have read and write access to the `/app/config` directory.
**Config folder permissions**: Since the container now runs as the `node` user (UID 1000), you must ensure your config folder has the correct permissions. The `node` user must have read and write access to the `/app/config` directory.
If you're migrating from a previous installation, you may need to update the ownership of your config folder:
```bash
@@ -136,12 +126,6 @@ Summary of changes :
</TabItem>
</Tabs>
## Build From Source
Refer to [Seerr Build From Source Documentation](/getting-started/buildfromsource), all of our examples have been updated to reflect the below change.
Install from scratch by following the documentation, restore your data as described in [Backups](/using-seerr/backups), and then start Seerr. No additional steps are required.
## Kubernetes
Refer to [Seerr Kubernetes Documentation](/getting-started/kubernetes), all of our examples have been updated to reflect the below change.
@@ -182,15 +166,3 @@ Summary of changes :
```
</TabItem>
</Tabs>
### Nix (Third-party installation methods)
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
### AUR (Third-party installation methods)
See https://aur.archlinux.org/packages/seerr
### TrueNAS (Third-party installation methods)
Waiting for https://github.com/truenas/apps/issues/3374

View File

@@ -7,7 +7,7 @@ Seerr docs will be available at [docs.seerr.dev](https://docs.seerr.dev).
### Installation
```
$ pnpm install
$ pnpm
```
### Local Development

View File

@@ -60,12 +60,12 @@
.table-of-contents__link--active,
a:not(
.card,
.menu__link,
.menu__link--sublist,
.menu__link--sublist-item,
.table-of-contents__link
) {
.card,
.menu__link,
.menu__link--sublist,
.menu__link--sublist-item,
.table-of-contents__link
) {
/* color: #793ae8; */
color: #6366f1;
}

21
merged-prettier-plugin.js Normal file
View File

@@ -0,0 +1,21 @@
/* eslint-disable */
const tailwind = require('prettier-plugin-tailwindcss');
const organizeImports = require('prettier-plugin-organize-imports');
const combinedFormatter = {
...tailwind,
parsers: {
...tailwind.parsers,
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
acc[key] = {
...tailwind.parsers[key],
preprocess(code, options) {
return organizeImports.parsers[key].preprocess(code, options);
},
};
return acc;
}, {}),
},
};
module.exports = combinedFormatter;

View File

@@ -5,6 +5,7 @@
"packageManager": "pnpm@10.24.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"postinstall": "node postinstall-win.js",
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
@@ -16,7 +17,7 @@
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
"format": "prettier --log-level warn --write --cache .",
"format": "prettier --loglevel warn --write --cache .",
"format:check": "prettier --check --cache .",
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
@@ -37,18 +38,18 @@
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.4.6",
"@formatjs/intl-utils": "3.8.4",
"@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.2.0",
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.56",
"@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2",
"ace-builds": "1.43.4",
"axios": "1.13.3",
"axios": "1.13.2",
"axios-rate-limit": "1.4.0",
"bcrypt": "6.0.0",
"bcrypt": "5.1.0",
"bowser": "2.13.1",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.7",
@@ -67,15 +68,16 @@
"gravatar-url": "3.1.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"lodash": "4.17.23",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.35",
"next": "^14.2.25",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.1",
"nodemailer": "7.0.12",
"openpgp": "6.3.0",
"pg": "8.17.2",
"nodemailer": "6.10.0",
"openpgp": "5.11.2",
"pg": "8.16.3",
"plex-api": "5.3.2",
"pug": "3.0.3",
"react": "^18.3.1",
"react-ace": "10.1.0",
@@ -88,6 +90,7 @@
"react-popper-tooltip": "4.4.2",
"react-select": "5.10.2",
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-transition-group": "^4.4.5",
"react-truncate-markup": "5.1.2",
@@ -98,28 +101,28 @@
"sharp": "^0.33.4",
"sqlite3": "5.1.7",
"swagger-ui-express": "4.6.2",
"swr": "2.3.8",
"swr": "2.3.7",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.28",
"typeorm": "0.3.12",
"ua-parser-js": "^1.0.35",
"undici": "^7.18.2",
"undici": "^7.16.0",
"validator": "^13.15.23",
"web-push": "3.6.7",
"wink-jaro-distance": "^2.0.0",
"winston": "3.19.0",
"winston": "3.18.3",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.5.0",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11",
"zod": "4.3.6"
"zod": "3.24.2"
},
"devDependencies": {
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/bcrypt": "6.0.0",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.10",
"@types/country-flag-icons": "1.2.2",
"@types/csurf": "1.11.5",
@@ -130,7 +133,7 @@
"@types/mime": "3",
"@types/node": "22.10.5",
"@types/node-schedule": "2.1.8",
"@types/nodemailer": "7",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-transition-group": "4.4.12",
@@ -139,20 +142,20 @@
"@types/swagger-ui-express": "4.1.8",
"@types/validator": "^13.15.10",
"@types/web-push": "3.6.4",
"@types/xml2js": "0.4.14",
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"autoprefixer": "^10.4.23",
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.22",
"baseline-browser-mapping": "^2.8.32",
"commitizen": "4.3.1",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "14.5.4",
"cypress": "14.1.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.57.1",
"eslint-config-next": "^14.2.35",
"eslint": "8.35.0",
"eslint-config-next": "^14.2.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@@ -163,20 +166,24 @@
"husky": "8.0.3",
"lint-staged": "13.1.2",
"nodemon": "3.1.11",
"postcss": "^8.5.6",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "3.4.19",
"postcss": "8.5.6",
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"tailwindcss": "3.2.7",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.4.5"
"typescript": "4.9.5"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^10.0.0"
},
"overrides": {
"sqlite3/node-gyp": "8.4.1",
"@types/express-session": "1.18.2"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
@@ -203,10 +210,6 @@
"cypress",
"sharp",
"sqlite3"
],
"overrides": {
"sqlite3>node-gyp": "8.4.1",
"@types/express-session": "1.18.2"
}
]
}
}

6208
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

13
postinstall-win.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
if (process.platform === 'win32') {
const typeormPath = path.resolve('node_modules/typeorm');
if (fs.existsSync(typeormPath)) {
process.stdout.write('> Installing typeorm@0.3.11 for Windows\n');
execSync('pnpm add typeorm@0.3.11', { stdio: 'inherit' });
}
}

View File

@@ -13,7 +13,6 @@ const DEFAULT_ROLLING_BUFFER = 10000;
export interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
timeout?: number;
rateLimit?: {
maxRPS: number;
maxRequests: number;
@@ -33,7 +32,6 @@ class ExternalAPI {
this.axios = axios.create({
baseURL: baseUrl,
params,
timeout: options.timeout,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',

View File

@@ -57,7 +57,7 @@ interface GithubCommit {
sha: string;
url: string;
html_url: string;
},
}
];
}

View File

@@ -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,

View File

@@ -1,14 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { Library, PlexSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
interface PlexStatusResponse {
MediaContainer: {
machineIdentifier: string;
friendlyName: string;
};
}
import NodePlexAPI from 'plex-api';
export interface PlexLibraryItem {
ratingKey: string;
@@ -91,7 +84,9 @@ interface PlexMetadataResponse {
};
}
class PlexAPI extends ExternalAPI {
class PlexAPI {
private plexClient: NodePlexAPI;
constructor({
plexToken,
plexSettings,
@@ -102,33 +97,48 @@ class PlexAPI extends ExternalAPI {
timeout?: number;
}) {
const settings = getSettings();
const settingsPlex = plexSettings ?? settings.plex;
let settingsPlex: PlexSettings | undefined;
plexSettings
? (settingsPlex = plexSettings)
: (settingsPlex = getSettings().plex);
const protocol = settingsPlex.useSsl ? 'https' : 'http';
const baseUrl = `${protocol}://${settingsPlex.ip}:${settingsPlex.port}`;
super(
baseUrl,
{},
{
timeout,
headers: {
'X-Plex-Token': plexToken ?? '',
'X-Plex-Client-Identifier': settings.clientId,
'X-Plex-Product': 'Seerr',
'X-Plex-Device-Name': 'Seerr',
'X-Plex-Platform': 'Seerr',
this.plexClient = new NodePlexAPI({
hostname: settingsPlex.ip,
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken ?? undefined,
authenticator: {
authenticate: (
_plexApi,
cb: (err?: string, token?: string) => void
) => {
if (!plexToken) {
return cb('Plex Token not found!');
}
cb(undefined, plexToken);
},
}
);
},
// requestOptions: {
// includeChildren: 1,
// },
options: {
identifier: settings.clientId,
product: 'Seerr',
deviceName: 'Seerr',
platform: 'Seerr',
},
});
}
public async getStatus(): Promise<PlexStatusResponse> {
return await this.get('/');
public async getStatus() {
return await this.plexClient.query('/');
}
public async getLibraries(): Promise<PlexLibrary[]> {
const response = await this.get<PlexLibrariesResponse>('/library/sections');
const response = await this.plexClient.query<PlexLibrariesResponse>(
'/library/sections'
);
return response.MediaContainer.Directory;
}
@@ -177,15 +187,13 @@ class PlexAPI extends ExternalAPI {
id: string,
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
const response = await this.get<PlexLibraryResponse>(
`/library/sections/${id}/all?includeGuids=1`,
{
headers: {
'X-Plex-Container-Start': `${offset}`,
'X-Plex-Container-Size': `${size}`,
},
}
);
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?includeGuids=1`,
extraHeaders: {
'X-Plex-Container-Start': `${offset}`,
'X-Plex-Container-Size': `${size}`,
},
});
return {
totalSize: response.MediaContainer.totalSize,
@@ -197,7 +205,7 @@ class PlexAPI extends ExternalAPI {
key: string,
options: { includeChildren?: boolean } = {}
): Promise<PlexMetadata> {
const response = await this.get<PlexMetadataResponse>(
const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}${
options.includeChildren ? '?includeChildren=1' : ''
}`
@@ -207,7 +215,7 @@ class PlexAPI extends ExternalAPI {
}
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
const response = await this.get<PlexMetadataResponse>(
const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}/children`
);
@@ -221,17 +229,15 @@ class PlexAPI extends ExternalAPI {
},
mediaType: 'movie' | 'show'
): Promise<PlexLibraryItem[]> {
const response = await this.get<PlexLibraryResponse>(
`/library/sections/${id}/all?type=${
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
{
headers: {
'X-Plex-Container-Start': '0',
'X-Plex-Container-Size': '500',
},
}
);
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,
},
});
return response.MediaContainer.Metadata;
}

View File

@@ -21,7 +21,7 @@ export const mapSounds = (sounds: {
({
name,
description,
}) as PushoverSound
} as PushoverSound)
);
class PushoverAPI extends ExternalAPI {

View File

@@ -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',

View File

@@ -92,13 +92,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
apiKey,
cacheName,
apiName,
timeout = 5000,
}: {
url: string;
apiKey: string;
cacheName: AvailableCacheIds;
apiName: string;
timeout?: number;
}) {
super(
url,
@@ -107,7 +105,6 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
},
{
nodeCache: cacheManager.getCache(cacheName).data,
timeout,
}
);

View File

@@ -64,16 +64,8 @@ export interface RadarrMovie {
}
class RadarrAPI extends ServarrBase<{ movieId: number }> {
constructor({
url,
apiKey,
timeout,
}: {
url: string;
apiKey: string;
timeout?: number;
}) {
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr', timeout });
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
}
public getMovies = async (): Promise<RadarrMovie[]> => {

View File

@@ -111,16 +111,8 @@ class SonarrAPI extends ServarrBase<{
episodeId: number;
episode: EpisodeResult;
}> {
constructor({
url,
apiKey,
timeout,
}: {
url: string;
apiKey: string;
timeout?: number;
}) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr', timeout });
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}
public async getSeries(): Promise<SonarrSeries[]> {
@@ -217,34 +209,6 @@ class SonarrAPI extends ServarrBase<{
series: newSeriesResponse.data,
});
try {
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
const episodeIdsToMonitor = episodes
.filter(
(ep) =>
options.seasons.includes(ep.seasonNumber) && !ep.monitored
)
.map((ep) => ep.id);
if (episodeIdsToMonitor.length > 0) {
logger.debug(
'Re-monitoring unmonitored episodes for requested seasons.',
{
label: 'Sonarr',
seriesId: newSeriesResponse.data.id,
episodeCount: episodeIdsToMonitor.length,
}
);
await this.monitorEpisodes(episodeIdsToMonitor);
}
} catch (e) {
logger.warn('Failed to re-monitor episodes', {
label: 'Sonarr',
errorMessage: e.message,
seriesId: newSeriesResponse.data.id,
});
}
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
}
@@ -354,38 +318,6 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
try {
const response = await this.axios.get<EpisodeResult[]>('/episode', {
params: { seriesId },
});
return response.data;
} catch (e) {
logger.error('Failed to retrieve episodes', {
label: 'Sonarr API',
errorMessage: e.message,
seriesId,
});
throw new Error('Failed to get episodes');
}
}
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
try {
await this.axios.put('/episode/monitor', {
episodeIds,
monitored: true,
});
} catch (e) {
logger.error('Failed to monitor episodes', {
label: 'Sonarr API',
errorMessage: e.message,
episodeIds,
});
throw new Error('Failed to monitor episodes');
}
}
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]

View File

@@ -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;

View File

@@ -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,

View File

@@ -392,10 +392,8 @@ export interface TmdbPersonCombinedCredits {
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends Omit<
TmdbTvSeasonResult,
'episode_count'
> {
export interface TmdbSeasonWithEpisodes
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}

View File

@@ -5,11 +5,11 @@ import { Watchlist } from '@server/entity/Watchlist';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import PreparedEmail from '@server/lib/email';
import type { PermissionCheckOptions } from '@server/lib/permissions';
import { Permission, hasPermission } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { AfterDate } from '@server/utils/dateHelpers';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import path from 'path';
@@ -271,7 +271,7 @@ export class User {
});
const movieQuotaLimit = !canBypass
? (this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit)
? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
: 0;
const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
@@ -295,7 +295,7 @@ export class User {
: 0;
const tvQuotaLimit = !canBypass
? (this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit)
? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
: 0;
const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;

View File

@@ -1,15 +1,8 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './User';
@Entity()
@Unique(['endpoint', 'user'])
export class UserPushSubscription {
@PrimaryGeneratedColumn()
public id: number;

View File

@@ -97,10 +97,7 @@ app
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(
settings.network.proxy,
settings.network.forceIpv4First
);
await createCustomProxyAgent(settings.network.proxy);
}
// Migrate library types

View File

@@ -7,10 +7,6 @@ export interface RequestResultsResponse extends PaginatedResponse {
profileName?: string;
canRemove?: boolean;
})[];
serviceErrors: {
radarr: { id: number; name: string }[];
sonarr: { id: number; name: string }[];
};
}
export type MediaRequestBody = {

View File

@@ -300,6 +300,7 @@ class AvailabilitySync {
// Sonarr finds that season, we will change the final seasons value
// to true.
const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -310,7 +311,48 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false)
);
// non-4k
const finalSeasons: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
plexSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
}
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -321,32 +363,44 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
let finalSeasons: Map<number, boolean>;
let finalSeasons4k: Map<number, boolean>;
// 4k
const finalSeasons4k: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
finalSeasons = new Map([
...filteredSeasonsMap,
...plexSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...plexSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
} else {
// Jellyfin/Emby
finalSeasons = new Map([
...filteredSeasonsMap,
...jellyfinSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...jellyfinSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
plexSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
}
if (
@@ -513,8 +567,8 @@ class AvailabilitySync {
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
? 'jellyfin'
: 'emby'
} instance. Status will be changed to deleted.`,
{ label: 'AvailabilitySync' }
);
@@ -588,8 +642,8 @@ class AvailabilitySync {
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
? 'jellyfin'
: 'emby'
} instance. Status will be changed to deleted.`,
{ label: 'AvailabilitySync' }
);
@@ -612,13 +666,6 @@ class AvailabilitySync {
): Promise<boolean> {
let existsInRadarr = false;
const hasSameServerInBothModes = this.radarrServers.some((a) =>
this.radarrServers.some(
(b) =>
a.is4k !== b.is4k && a.hostname === b.hostname && a.port === b.port
)
);
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
for (const server of this.radarrServers.filter(
@@ -649,14 +696,7 @@ class AvailabilitySync {
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000;
if (hasSameServerInBothModes && resolution?.length === 2) {
// Same server in both modes then use resolution to distinguish
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
} else {
// One server type and if file exists, count it
existsInRadarr = true;
}
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
}
} catch (ex) {
if (!ex.message.includes('404')) {
@@ -672,8 +712,6 @@ class AvailabilitySync {
);
}
}
if (existsInRadarr) break;
}
return existsInRadarr;
@@ -832,50 +870,6 @@ class AvailabilitySync {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
if (plexMedia) {
if (ratingKey === ratingKey4k) {
plexMedia = undefined;
}
if (
plexMedia &&
media.mediaType === 'movie' &&
!plexMedia.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
) {
plexMedia = undefined;
}
if (plexMedia && media.mediaType === 'tv') {
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
if (cachedSeasons?.length) {
let has4kInAnySeason = false;
for (const season of cachedSeasons) {
try {
const episodes = await this.plexClient?.getChildrenMetadata(
season.ratingKey
);
const has4kEpisode = episodes?.some((episode) =>
episode.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
);
if (has4kEpisode) {
has4kInAnySeason = true;
break;
}
} catch {
// If we can't fetch episodes for a season, continue checking other seasons
}
}
if (!has4kInAnySeason) {
plexMedia = undefined;
}
}
}
}
}
if (plexMedia) {
@@ -999,8 +993,8 @@ class AvailabilitySync {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = true;
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

@@ -2,12 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -209,8 +209,8 @@ class DiscordAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
return {

View File

@@ -4,7 +4,7 @@ import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import PreparedEmail from '@server/lib/email';
import type { NotificationAgentEmail } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import type { EmailOptions } from 'email-templates';
import path from 'path';

View File

@@ -3,7 +3,7 @@ import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';

View File

@@ -3,7 +3,7 @@ import type { NotificationAgentNtfy } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';

View File

@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';

View File

@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -45,17 +45,7 @@ class PushoverAgent
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
return true;
}
private async getImagePayload(
@@ -158,8 +148,8 @@ class PushoverAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
const url_title = url
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`

View File

@@ -3,7 +3,7 @@ import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -183,8 +183,8 @@ class SlackAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {

View File

@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -133,8 +133,8 @@ class TelegramAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {

View File

@@ -5,7 +5,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -122,7 +122,7 @@ class WebhookAgent
`{{${keymapKey}}}`,
typeof keymapValue === 'function'
? keymapValue(payload, type)
: (get(payload, keymapValue) ?? '')
: get(payload, keymapValue) ?? ''
);
});
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
@@ -186,8 +186,8 @@ class WebhookAgent
type === Notification.TEST_NOTIFICATION
? 'test'
: typeof keymapValue === 'function'
? keymapValue(payload, type)
: get(payload, keymapValue) || 'test';
? keymapValue(payload, type)
: get(payload, keymapValue) || 'test';
webhookUrl = webhookUrl.replace(
new RegExp(`{{${keymapKey}}}`, 'g'),
encodeURIComponent(variableValue)

View File

@@ -5,7 +5,7 @@ import MediaRequest from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { NotificationAgentConfig } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import webpush from 'web-push';
import { Notification, shouldSendAdminNotification } from '..';
@@ -24,15 +24,6 @@ interface PushNotificationPayload {
isAdmin?: boolean;
}
interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent
@@ -128,8 +119,8 @@ class WebPushAgent
const actionUrl = payload.issue
? `/issues/${payload.issue.id}`
: payload.media
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
const actionUrlTitle = actionUrl
? `View ${payload.issue ? 'Issue' : 'Media'}`
@@ -197,30 +188,19 @@ class WebPushAgent
notificationPayload
);
} catch (e) {
const webPushError = e as WebPushError;
const statusCode = webPushError.statusCode || webPushError.status;
const errorMessage = webPushError.message || String(e);
// RFC 8030: 410/404 are permanent failures, others are transient
const isPermanentFailure = statusCode === 410 || statusCode === 404;
logger.error(
isPermanentFailure
? 'Error sending web push notification; removing invalid subscription'
: 'Error sending web push notification (transient error, keeping subscription)',
'Error sending web push notification; removing subscription',
{
label: 'Notifications',
recipient: pushSub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage,
statusCode: statusCode || 'unknown',
errorMessage: e.message,
}
);
if (isPermanentFailure) {
await userPushSubRepository.remove(pushSub);
}
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(pushSub);
}
};

View File

@@ -115,11 +115,9 @@ class BaseScanner<T> {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.AVAILABLE
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
@@ -200,14 +198,14 @@ class BaseScanner<T> {
!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;
@@ -327,17 +325,12 @@ class BaseScanner<T> {
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status;
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
@@ -347,17 +340,12 @@ class BaseScanner<T> {
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k;
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
@@ -366,20 +354,20 @@ class BaseScanner<T> {
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
@@ -486,37 +474,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 {
@@ -567,31 +555,31 @@ class BaseScanner<T> {
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}`);

View File

@@ -20,6 +20,7 @@ import type {
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import serviceAvailabilityChecker from '@server/lib/scanners/serviceAvailabilityChecker';
import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { getHostname } from '@server/utils/getHostname';
@@ -125,6 +126,57 @@ class JellyfinScanner
const { tmdbId, imdbId, metadata } = extracted;
const mediaAddedAt = metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined;
if (this.enable4kMovie) {
const instanceAvailability =
await serviceAvailabilityChecker.checkMovieAvailability(tmdbId);
if (instanceAvailability.hasStandard || instanceAvailability.has4k) {
if (instanceAvailability.hasStandard) {
await this.processMovie(tmdbId, {
is4k: false,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
if (instanceAvailability.has4k) {
await this.processMovie(tmdbId, {
is4k: true,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
this.log(
`Processed movie with service availability check: ${metadata.Name}`,
'debug',
{
tmdbId,
hasStandard: instanceAvailability.hasStandard,
has4k: instanceAvailability.has4k,
}
);
return;
}
this.log(
`Movie not found in any Radarr instance, using resolution-based detection: ${metadata.Name}`,
'debug',
{
tmdbId,
}
);
}
const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video'
@@ -141,10 +193,6 @@ class JellyfinScanner
});
});
const mediaAddedAt = metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined;
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
await this.processMovie(tmdbId, {
is4k: false,
@@ -285,6 +333,34 @@ class JellyfinScanner
? seasons
: seasons.filter((sn) => sn.season_number !== 0);
let instanceAvailability: Awaited<
ReturnType<typeof serviceAvailabilityChecker.checkShowAvailability>
> | null = null;
let useServiceBasedDetection = false;
if (this.enable4kShow && tvShow.external_ids?.tvdb_id) {
instanceAvailability =
await serviceAvailabilityChecker.checkShowAvailability(
tvShow.external_ids.tvdb_id
);
useServiceBasedDetection =
instanceAvailability.hasStandard || instanceAvailability.has4k;
if (useServiceBasedDetection) {
this.log(
`Using service availability check for show: ${tvShow.name}`,
'debug',
{
tvdbId: tvShow.external_ids.tvdb_id,
hasStandard: instanceAvailability.hasStandard,
has4k: instanceAvailability.has4k,
seasons: instanceAvailability.seasons.length,
}
);
}
}
for (const season of filteredSeasons) {
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
if (tvdbSeasonFromAnidb) {
@@ -306,7 +382,16 @@ class JellyfinScanner
let totalStandard = 0;
let total4k = 0;
if (!this.enable4kShow) {
if (useServiceBasedDetection && instanceAvailability) {
const serviceSeason = instanceAvailability.seasons.find(
(s) => s.seasonNumber === season.season_number
);
if (serviceSeason) {
totalStandard = serviceSeason.episodesStandard;
total4k = serviceSeason.episodes4k;
}
} else if (!this.enable4kShow) {
const episodes = await this.jfClient.getEpisodes(
Id,
matchedJellyfinSeason.Id
@@ -362,14 +447,6 @@ class JellyfinScanner
)
);
// Count in both if episode has both versions
// TODO: Make this more robust in the future
// Currently, this detection is based solely on file resolution, not which
// Radarr/Sonarr instance the file came from. If a 4K request results in
// 1080p files (no 4K release available yet), those files will be counted
// as "standard" even though they're in the 4K library. This can cause
// non-4K users to see content as "available" when they can't access it.
// See issue https://github.com/seerr-team/seerr/issues/1744 for details.
if (hasStandard) totalStandard += episodeCount;
if (has4k) total4k += episodeCount;
}
@@ -452,6 +529,8 @@ class JellyfinScanner
const sessionId = this.startRun();
serviceAvailabilityChecker.clearCache();
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({

View File

@@ -0,0 +1,193 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
interface InstanceAvailability {
hasStandard: boolean;
has4k: boolean;
serviceStandardId?: number;
service4kId?: number;
externalStandardId?: number;
external4kId?: number;
}
interface SeasonInstanceAvailability {
seasonNumber: number;
episodesStandard: number;
episodes4k: number;
}
interface ShowInstanceAvailability extends InstanceAvailability {
seasons: SeasonInstanceAvailability[];
}
class ServiceAvailabilityChecker {
private movieCache: Map<number, InstanceAvailability>;
private showCache: Map<number, ShowInstanceAvailability>;
constructor() {
this.movieCache = new Map();
this.showCache = new Map();
}
public clearCache(): void {
this.movieCache.clear();
this.showCache.clear();
}
public async checkMovieAvailability(
tmdbid: number
): Promise<InstanceAvailability> {
const cached = this.movieCache.get(tmdbid);
if (cached) {
return cached;
}
const settings = getSettings();
const result: InstanceAvailability = {
hasStandard: false,
has4k: false,
};
if (!settings.radarr || settings.radarr.length === 0) {
return result;
}
for (const radarrSettings of settings.radarr) {
try {
const radarr = this.createRadarrClient(radarrSettings);
const movie = await radarr.getMovieByTmdbId(tmdbid);
if (movie?.hasFile) {
if (radarrSettings.is4k) {
result.has4k = true;
result.service4kId = radarrSettings.id;
result.external4kId = movie.id;
} else {
result.hasStandard = true;
result.serviceStandardId = radarrSettings.id;
result.externalStandardId = movie.id;
}
}
logger.debug(
`Found movie (TMDB: ${tmdbid}) in ${
radarrSettings.is4k ? '4K' : 'Standard'
} Radarr instance (name: ${radarrSettings.name})`,
{
label: 'Service Availability',
radarrId: radarrSettings.id,
movieId: movie?.id,
}
);
} catch {
// movie not found in this instance, continue
}
}
this.movieCache.set(tmdbid, result);
return result;
}
public async checkShowAvailability(
tvdbid: number
): Promise<ShowInstanceAvailability> {
const cached = this.showCache.get(tvdbid);
if (cached) {
return cached;
}
const settings = getSettings();
const result: ShowInstanceAvailability = {
hasStandard: false,
has4k: false,
seasons: [],
};
if (!settings.sonarr || settings.sonarr.length === 0) {
return result;
}
const standardSeasons = new Map<number, number>();
const seasons4k = new Map<number, number>();
for (const sonarrSettings of settings.sonarr) {
try {
const sonarr = this.createSonarrClient(sonarrSettings);
const series = await sonarr.getSeriesByTvdbId(tvdbid);
if (series?.id && series.statistics?.episodeFileCount > 0) {
if (sonarrSettings.is4k) {
result.has4k = true;
result.service4kId = sonarrSettings.id;
result.external4kId = series.id;
} else {
result.hasStandard = true;
result.serviceStandardId = sonarrSettings.id;
result.externalStandardId = series.id;
}
for (const season of series.seasons) {
const episodeCount = season.statistics?.episodeFileCount ?? 0;
if (episodeCount > 0) {
const targetMap = sonarrSettings.is4k
? seasons4k
: standardSeasons;
const current = targetMap.get(season.seasonNumber) ?? 0;
targetMap.set(
season.seasonNumber,
Math.max(current, episodeCount)
);
}
}
logger.debug(
`Found series (TVDB: ${tvdbid}) in ${
sonarrSettings.is4k ? '4K' : 'Standard'
} Sonarr instance (name: ${sonarrSettings.name})`,
{
label: 'Service Availability',
sonarrId: sonarrSettings.id,
seriesId: series.id,
}
);
}
} catch {
// series not found in this instance, continue
}
}
const allSeasonNumbers = new Set([
...standardSeasons.keys(),
...seasons4k.keys(),
]);
result.seasons = Array.from(allSeasonNumbers).map((seasonNumber) => ({
seasonNumber,
episodesStandard: standardSeasons.get(seasonNumber) ?? 0,
episodes4k: seasons4k.get(seasonNumber) ?? 0,
}));
this.showCache.set(tvdbid, result);
return result;
}
private createRadarrClient(settings: RadarrSettings): RadarrAPI {
return new RadarrAPI({
url: RadarrAPI.buildUrl(settings, '/api/v3'),
apiKey: settings.apiKey,
});
}
private createSonarrClient(settings: SonarrSettings): SonarrAPI {
return new SonarrAPI({
url: SonarrAPI.buildUrl(settings, '/api/v3'),
apiKey: settings.apiKey,
});
}
}
const serviceAvailabilityChecker = new ServiceAvailabilityChecker();
export default serviceAvailabilityChecker;

View File

@@ -59,11 +59,12 @@ searchProviders.push({
const successfulResponses = responses.filter(
(r) => r.status === 'fulfilled'
) as (
| PromiseFulfilledResult<TmdbMovieDetails>
| PromiseFulfilledResult<TmdbTvDetails>
| PromiseFulfilledResult<TmdbPersonDetails>
)[];
) as
| (
| PromiseFulfilledResult<TmdbMovieDetails>
| PromiseFulfilledResult<TmdbTvDetails>
| PromiseFulfilledResult<TmdbPersonDetails>
)[];
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
@@ -184,10 +185,11 @@ searchProviders.push({
const successfulResponses = responses.filter(
(r) => r.status === 'fulfilled'
) as (
| PromiseFulfilledResult<TmdbSearchMovieResponse>
| PromiseFulfilledResult<TmdbSearchTvResponse>
)[];
) as
| (
| PromiseFulfilledResult<TmdbSearchMovieResponse>
| PromiseFulfilledResult<TmdbSearchTvResponse>
)[];
const results: (TmdbMovieResult | TmdbTvResult)[] = [];

View File

@@ -13,7 +13,9 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}
const userRepository = getRepository(User);
const users = await userRepository.find();
const users = await userRepository.find({
select: ['id'],
});
let errorOccurred = false;
@@ -28,26 +30,15 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
});
const radarrTags = await radarr.getTags();
for (const user of users) {
const userTag = radarrTags.find(
(v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
const userTag = radarrTags.find((v) =>
v.label.startsWith(user.id + ' - ')
);
if (!userTag) {
continue;
}
await radarr.renameTag({
id: userTag.id,
label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
});
}
} catch (error) {
@@ -70,26 +61,15 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
});
const sonarrTags = await sonarr.getTags();
for (const user of users) {
const userTag = sonarrTags.find(
(v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
const userTag = sonarrTags.find((v) =>
v.label.startsWith(user.id + ' - ')
);
if (!userTag) {
continue;
}
await sonarr.renameTag({
id: userTag.id,
label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
});
}
} catch (error) {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramMessageThreadId1734786596045 implements MigrationInterface {
export class AddTelegramMessageThreadId1734786596045
implements MigrationInterface
{
name = 'AddTelegramMessageThreadId1734786596045';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAvatarCacheFields1743107707465 implements MigrationInterface {
export class AddUserAvatarCacheFields1743107707465
implements MigrationInterface
{
name = 'AddUserAvatarCacheFields1743107707465';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,26 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
);
}
}

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface {
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface
{
name = 'AddUserRequestDeleteCascades1608219049304';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface {
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface
{
name = 'AddLastSeasonChangeMedia1608477467935';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface {
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface
{
name = 'ForceDropImdbUniqueConstraint1608477467936';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface {
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface
{
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface {
export class AddMediaAddedFieldToMedia1610522845513
implements MigrationInterface
{
name = 'AddMediaAddedFieldToMedia1610522845513';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface {
export class SonarrRadarrSyncServiceFields1611757511674
implements MigrationInterface
{
name = 'SonarrRadarrSyncServiceFields1611757511674';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface {
export class AddResetPasswordGuidAndExpiryDate1612482778137
implements MigrationInterface
{
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface {
export class UpdateUserSettingsRegions1613955393450
implements MigrationInterface
{
name = 'UpdateUserSettingsRegions1613955393450';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface {
export class AddTelegramSettingsToUserSettings1614334195680
implements MigrationInterface
{
name = 'AddTelegramSettingsToUserSettings1614334195680';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface {
export class CreateTagsFieldonMediaRequest1617624225464
implements MigrationInterface
{
name = 'CreateTagsFieldonMediaRequest1617624225464';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface {
export class AddUserSettingsNotificationAgentsField1617730837489
implements MigrationInterface
{
name = 'AddUserSettingsNotificationAgentsField1617730837489';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface {
export class CreateUserPushSubscriptions1618912653565
implements MigrationInterface
{
name = 'CreateUserPushSubscriptions1618912653565';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface {
export class AddUserSettingsNotificationTypes1619339817343
implements MigrationInterface
{
name = 'AddUserSettingsNotificationTypes1619339817343';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface {
export class AddPushbulletPushoverUserSettings1635079863457
implements MigrationInterface
{
name = 'AddPushbulletPushoverUserSettings1635079863457';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWatchlistSyncUserSetting1660632269368 implements MigrationInterface {
export class AddWatchlistSyncUserSetting1660632269368
implements MigrationInterface
{
name = 'AddWatchlistSyncUserSetting1660632269368';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaRequestIsAutoRequestedField1660714479373 implements MigrationInterface {
export class AddMediaRequestIsAutoRequestedField1660714479373
implements MigrationInterface
{
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsStreamingRegion1727907530757 implements MigrationInterface {
export class AddUserSettingsStreamingRegion1727907530757
implements MigrationInterface
{
name = 'AddUserSettingsStreamingRegion1727907530757';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramMessageThreadId1734287582736 implements MigrationInterface {
export class AddTelegramMessageThreadId1734287582736
implements MigrationInterface
{
name = 'AddTelegramMessageThreadId1734287582736';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAvatarCacheFields1743107645301 implements MigrationInterface {
export class AddUserAvatarCacheFields1743107645301
implements MigrationInterface
{
name = 'AddUserAvatarCacheFields1743107645301';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,24 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
}
}

View File

@@ -7,7 +7,6 @@ import type {
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type { Video } from './Movie';
import type {
Cast,
Crew,
@@ -25,6 +24,7 @@ import {
mapVideos,
mapWatchProviders,
} from './common';
import type { Video } from './Movie';
interface Episode {
id: number;

View File

@@ -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]);

View File

@@ -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) {

View File

@@ -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';

View File

@@ -275,24 +275,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
page: Math.ceil(skip / pageSize) + 1,
},
results: mappedRequests,
serviceErrors: {
radarr: radarrServers
.filter((s) => !s.profiles)
.map((s) => ({
id: s.id,
name:
settings.radarr.find((r) => r.id === s.id)?.name ||
`Radarr ${s.id}`,
})),
sonarr: sonarrServers
.filter((s) => !s.profiles)
.map((s) => ({
id: s.id,
name:
settings.sonarr.find((r) => r.id === s.id)?.name ||
`Sonarr ${s.id}`,
})),
},
});
} catch (e) {
next({ status: 500, message: e.message });

View File

@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
@@ -17,7 +17,7 @@ import type {
UserResultsResponse,
UserWatchDataResponse,
} from '@server/interfaces/api/userInterfaces';
import { Permission, hasPermission } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@@ -25,8 +25,7 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import { In } from 'typeorm';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -189,82 +188,30 @@ router.post<
}
>('/registerPushSubscription', async (req, res, next) => {
try {
// This prevents race conditions where two requests both pass the checks
await dataSource.transaction(
async (transactionalEntityManager: EntityManager) => {
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);
const userPushSubRepository = getRepository(UserPushSubscription);
// Check for existing subscription by auth or endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true },
where: [
{ auth: req.body.auth, user: { id: req.user?.id } },
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
],
});
const existingSubs = await userPushSubRepository.find({
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});
if (existingSubscription) {
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
if (
existingSubscription.endpoint === req.body.endpoint &&
existingSubscription.auth !== req.body.auth
) {
existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;
if (existingSubs.length > 0) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}
await transactionalRepo.save(existingSubscription);
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
logger.debug(
'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' }
);
return;
}
logger.debug(
'Duplicate subscription detected. Skipping registration.',
{ label: 'API' }
);
return;
}
// Clean up old subscriptions from the same device (userAgent) for this user
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
// Only clean up if we're creating a new subscription (not updating an existing one)
if (req.body.userAgent) {
const staleSubscriptions = await transactionalRepo.find({
relations: { user: true },
where: {
userAgent: req.body.userAgent,
user: { id: req.user?.id },
// Only remove subscriptions with different endpoints (stale ones)
// Keep subscriptions that might be from different browsers/tabs
endpoint: Not(req.body.endpoint),
},
});
if (staleSubscriptions.length > 0) {
await transactionalRepo.remove(staleSubscriptions);
logger.debug(
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
{ label: 'API' }
);
}
}
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
await transactionalRepo.save(userPushSubscription);
}
);
userPushSubRepository.save(userPushSubscription);
return res.status(204).send();
} catch (e) {

View File

@@ -13,7 +13,9 @@ import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
import { EventSubscriber } from 'typeorm';
@EventSubscriber()
export class IssueCommentSubscriber implements EntitySubscriberInterface<IssueComment> {
export class IssueCommentSubscriber
implements EntitySubscriberInterface<IssueComment>
{
public listenTo(): typeof IssueComment {
return IssueComment;
}

View File

@@ -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,

View File

@@ -15,7 +15,6 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import notificationManager, { Notification } from '@server/lib/notifications';
import { getSettings } from '@server/lib/settings';
@@ -28,20 +27,12 @@ import type {
RemoveEvent,
UpdateEvent,
} from 'typeorm';
import { EventSubscriber, Not } from 'typeorm';
const sanitizeDisplayName = (displayName: string): string => {
return displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
import { EventSubscriber } from 'typeorm';
@EventSubscriber()
export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRequest> {
export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest>
{
private async notifyAvailableMovie(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
@@ -319,15 +310,11 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
userTag = await radarr.createTag({
label:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
}
if (userTag.id) {
@@ -398,23 +385,10 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
await mediaRepository.save(media);
})
.catch(async () => {
try {
const requestRepository = getRepository(MediaRequest);
const requestRepository = getRepository(MediaRequest);
if (entity.status !== MediaRequestStatus.FAILED) {
entity.status = MediaRequestStatus.FAILED;
await requestRepository.save(entity);
}
} catch (saveError) {
logger.error('Failed to mark request as FAILED', {
label: 'Media Request',
requestId: entity.id,
errorMessage:
saveError instanceof Error
? saveError.message
: String(saveError),
});
}
entity.status = MediaRequestStatus.FAILED;
requestRepository.save(entity);
logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED',
@@ -517,6 +491,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
const media = await mediaRepository.findOne({
where: { id: entity.media.id },
relations: { requests: true },
});
if (!media) {
@@ -584,8 +559,8 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
? [...sonarrSettings.tags]
: [];
if (
entity.rootFolder &&
@@ -656,15 +631,11 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
}
if (userTag.id) {
@@ -703,6 +674,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: entity.media.id },
relations: { requests: true },
});
if (!media) {
@@ -719,23 +691,10 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
await mediaRepository.save(media);
})
.catch(async () => {
try {
const requestRepository = getRepository(MediaRequest);
const requestRepository = getRepository(MediaRequest);
if (entity.status !== MediaRequestStatus.FAILED) {
entity.status = MediaRequestStatus.FAILED;
await requestRepository.save(entity);
}
} catch (saveError) {
logger.error('Failed to mark request as FAILED', {
label: 'Media Request',
requestId: entity.id,
errorMessage:
saveError instanceof Error
? saveError.message
: String(saveError),
});
}
entity.status = MediaRequestStatus.FAILED;
requestRepository.save(entity);
logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED',
@@ -783,6 +742,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: entity.media.id },
relations: { requests: true },
});
if (!media) {
logger.error('Media data not found', {
@@ -792,29 +752,26 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
});
return;
}
const statusKey = entity.is4k ? 'status4k' : 'status';
const seasonRequestRepository = getRepository(SeasonRequest);
const requestRepository = getRepository(MediaRequest);
if (
entity.status === MediaRequestStatus.APPROVED &&
// Do not update the status if the item is already partially available or available
media[statusKey] !== MediaStatus.AVAILABLE &&
media[statusKey] !== MediaStatus.PARTIALLY_AVAILABLE &&
media[statusKey] !== MediaStatus.PROCESSING
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[entity.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE &&
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
) {
media[statusKey] = MediaStatus.PROCESSING;
await mediaRepository.save(media);
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
mediaRepository.save(media);
}
if (
media.mediaType === MediaType.MOVIE &&
entity.status === MediaRequestStatus.DECLINED &&
media[statusKey] !== MediaStatus.DELETED
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
) {
media[statusKey] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
/**
@@ -826,72 +783,14 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
if (
media.mediaType === MediaType.TV &&
entity.status === MediaRequestStatus.DECLINED &&
media[statusKey] === MediaStatus.PENDING
media.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING
).length === 0 &&
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
) {
const requestRepository = getRepository(MediaRequest);
const pendingCount = await requestRepository.count({
where: {
media: { id: media.id },
status: MediaRequestStatus.PENDING,
is4k: entity.is4k,
id: Not(entity.id),
},
});
if (pendingCount === 0) {
// Re-fetch media without requests to avoid cascade issues
const freshMedia = await mediaRepository.findOne({
where: { id: media.id },
});
if (freshMedia) {
freshMedia[statusKey] = MediaStatus.UNKNOWN;
await mediaRepository.save(freshMedia);
}
}
}
// Reset season statuses when a TV request is declined
if (
media.mediaType === MediaType.TV &&
entity.status === MediaRequestStatus.DECLINED
) {
const seasonRepository = getRepository(Season);
const actualSeasons = await seasonRepository.find({
where: { media: { id: media.id } },
});
for (const seasonRequest of entity.seasons) {
seasonRequest.status = MediaRequestStatus.DECLINED;
await seasonRequestRepository.save(seasonRequest);
const season = actualSeasons.find(
(s) => s.seasonNumber === seasonRequest.seasonNumber
);
if (season && season[statusKey] == MediaStatus.PENDING) {
const otherActiveRequests = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.seasons', 'season')
.where('request.mediaId = :mediaId', { mediaId: media.id })
.andWhere('request.id != :requestId', { requestId: entity.id })
.andWhere('request.is4k = :is4k', { is4k: entity.is4k })
.andWhere('request.status NOT IN (:...statuses)', {
statuses: [
MediaRequestStatus.DECLINED,
MediaRequestStatus.COMPLETED,
],
})
.andWhere('season.seasonNumber = :seasonNumber', {
seasonNumber: season.seasonNumber,
})
.getCount();
if (otherActiveRequests === 0) {
season[statusKey] = MediaStatus.UNKNOWN;
await seasonRepository.save(season);
}
}
}
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
// Approve child seasons if parent is approved
@@ -915,74 +814,54 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
relations: { requests: true },
});
const needsStatusUpdate =
if (!fullMedia) return;
if (
!fullMedia.requests.some((request) => !request.is4k) &&
fullMedia.status !== MediaStatus.AVAILABLE;
fullMedia.status !== MediaStatus.AVAILABLE
) {
fullMedia.status = MediaStatus.UNKNOWN;
}
const needs4kStatusUpdate =
if (
!fullMedia.requests.some((request) => request.is4k) &&
fullMedia.status4k !== MediaStatus.AVAILABLE;
if (needsStatusUpdate || needs4kStatusUpdate) {
// Re-fetch WITHOUT requests to avoid cascade issues on save
const cleanMedia = await manager.findOneOrFail(Media, {
where: { id: entity.media.id },
});
if (needsStatusUpdate) {
cleanMedia.status = MediaStatus.UNKNOWN;
}
if (needs4kStatusUpdate) {
cleanMedia.status4k = MediaStatus.UNKNOWN;
}
await manager.save(cleanMedia);
fullMedia.status4k !== MediaStatus.AVAILABLE
) {
fullMedia.status4k = MediaStatus.UNKNOWN;
}
await manager.save(fullMedia);
}
public async afterUpdate(event: UpdateEvent<MediaRequest>): Promise<void> {
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
if (!event.entity) {
return;
}
try {
await this.sendToRadarr(event.entity as MediaRequest);
await this.sendToSonarr(event.entity as MediaRequest);
await this.updateParentStatus(event.entity as MediaRequest);
this.sendToRadarr(event.entity as MediaRequest);
this.sendToSonarr(event.entity as MediaRequest);
if (event.entity.status === MediaRequestStatus.COMPLETED) {
if (event.entity.media.mediaType === MediaType.MOVIE) {
await this.notifyAvailableMovie(event.entity as MediaRequest, event);
}
if (event.entity.media.mediaType === MediaType.TV) {
await this.notifyAvailableSeries(event.entity as MediaRequest, event);
}
this.updateParentStatus(event.entity as MediaRequest);
if (event.entity.status === MediaRequestStatus.COMPLETED) {
if (event.entity.media.mediaType === MediaType.MOVIE) {
this.notifyAvailableMovie(event.entity as MediaRequest, event);
}
if (event.entity.media.mediaType === MediaType.TV) {
this.notifyAvailableSeries(event.entity as MediaRequest, event);
}
} catch (e) {
logger.error('Error in afterUpdate subscriber', {
label: 'Media Request',
requestId: (event.entity as MediaRequest).id,
errorMessage: e instanceof Error ? e.message : String(e),
});
}
}
public async afterInsert(event: InsertEvent<MediaRequest>): Promise<void> {
public afterInsert(event: InsertEvent<MediaRequest>): void {
if (!event.entity) {
return;
}
try {
await this.sendToRadarr(event.entity as MediaRequest);
await this.sendToSonarr(event.entity as MediaRequest);
await this.updateParentStatus(event.entity as MediaRequest);
} catch (e) {
logger.error('Error in afterInsert subscriber', {
label: 'Media Request',
requestId: (event.entity as MediaRequest).id,
errorMessage: e instanceof Error ? e.message : String(e),
});
}
this.sendToRadarr(event.entity as MediaRequest);
this.sendToSonarr(event.entity as MediaRequest);
this.updateParentStatus(event.entity as MediaRequest);
}
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {

View File

@@ -1,10 +1,7 @@
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 Normal file
View File

@@ -0,0 +1,33 @@
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>;
}
}

View File

@@ -11,14 +11,9 @@ export let requestInterceptorFunction: (
) => InternalAxiosRequestConfig;
export default async function createCustomProxyAgent(
proxySettings: ProxySettings,
forceIpv4First?: boolean
proxySettings: ProxySettings
) {
const defaultAgent = new Agent({
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
const skipUrl = (url: string | URL) => {
const hostname =
@@ -72,23 +67,16 @@ export default async function createCustomProxyAgent(
uri: proxyUrl,
token,
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
const agentOptions = {
axios.defaults.httpAgent = new HttpProxyAgent(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);
});
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined,
});
requestInterceptorFunction = (config) => {
const url = config.baseURL

View File

@@ -385,7 +385,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</div>
</div>
<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="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="card-field">
<span className="card-field-name">Status</span>
<Badge badgeType="danger">

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