Compare commits

..

2 Commits

Author SHA1 Message Date
fallenbagel
959a40ff20 docs: rename remaining jellyseerr references to seerr 2025-10-31 08:06:48 +08:00
fallenbagel
137df4c9c8 docs(using-jellyseerr): renamed advanced section from jellyseerr -> seerr 2025-10-31 07:58:15 +08:00
232 changed files with 8467 additions and 6377 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

@@ -91,14 +91,6 @@ body:
attributes:
label: Additional Context
description: Please provide any additional information that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this bug has already been reported?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes
id: terms
attributes:

View File

@@ -27,14 +27,6 @@ body:
attributes:
label: Additional Context
description: Provide any additional information or screenshots that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this feature has already been requested?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes
id: terms
attributes:

View File

@@ -1,33 +1,14 @@
<!--
Please read contributing guide before submitting
your pull request. Please fill in each section below to help us better prioritize your pull request. Thanks!
-->
#### Description
## Description
#### Screenshot (if UI-related)
<!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
#### To-Dos
- Fixes #XXXX
## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
## Screenshots / Logs (if applicable)
## Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] I have read and followed the contribution [guidelines](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md).
- [ ] Disclosed any use of AI (see our [policy](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice))
- [ ] I have updated the documentation accordingly.
- [ ] All new and existing tests passed.
- [ ] Disclosed any use of AI (see our [policy](../CONTRIBUTING.md#ai-assistance-notice))
- [ ] Successful build `pnpm build`
- [ ] Translation keys `pnpm i18n:extract`
- [ ] Database migration (if required)
#### Issues Fixed or Closed
- Fixes #XXXX

View File

@@ -1,23 +1,20 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'Seerr Labeller'
name: 'AI-generated pull requests'
on:
pull_request_target:
types: [labeled, unlabeled, reopened]
issues:
types: [labeled, unlabeled, reopened]
permissions: {}
permissions:
pull-requests: read
jobs:
ai-generated-support:
if: >
github.event_name == 'pull_request_target' &&
(github.event.label.name == 'ai-generated' || (github.event.action == 'reopened' && contains(github.event.pull_request.labels.*.name, 'ai-generated')))
if: github.event.label.name == 'ai-generated' || github.event.action == 'reopened'
runs-on: ubuntu-24.04
concurrency:
group: ai-generated-${{ github.event.pull_request.number }}
group: support-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
pull-requests: write
@@ -46,7 +43,7 @@ jobs:
retry gh pr close "$NUMBER" -R "$GH_REPO" || true
gh pr lock "$NUMBER" -R "$GH_REPO" -r "spam" || true
- name: Label removed, reopen and unlock pull request
- name: Reopened or label removed, unlock pull request
if: github.event.action == 'unlabeled' && github.event.label.name == 'ai-generated'
shell: bash
run: |
@@ -60,52 +57,3 @@ jobs:
run: |
gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "ai-generated" || true
gh pr unlock "$NUMBER" -R "$GH_REPO" || true
support:
if: >
github.event_name == 'issues' &&
(github.event.label.name == 'support' ||
(github.event.action == 'reopened' && contains(github.event.issue.labels.*.name, 'support')))
runs-on: ubuntu-24.04
concurrency:
group: support-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
issues: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
steps:
- name: Label added, comment and close issue
if: github.event.action == 'labeled' && github.event.label.name == 'support'
shell: bash
env:
BODY: >
:wave: @${{ env.ISSUE_AUTHOR }}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use our support channels
to get help with Seerr.
- [Discord](https://discord.gg/seerr)
run: |
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
retry gh issue comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
retry gh issue close "$NUMBER" -R "$GH_REPO" || true
gh issue lock "$NUMBER" -R "$GH_REPO" -r "off_topic" || true
- name: Label removed, reopen and unlock issue
if: github.event.action == 'unlabeled' && github.event.label.name == 'support'
shell: bash
run: |
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
retry gh issue reopen "$NUMBER" -R "$GH_REPO" || true
gh issue unlock "$NUMBER" -R "$GH_REPO" || true
- name: Remove support label on manual reopen
if: github.event.action == 'reopened'
shell: bash
run: |
gh issue edit "$NUMBER" -R "$GH_REPO" --remove-label "support" || true
gh issue unlock "$NUMBER" -R "$GH_REPO" || true

View File

@@ -14,9 +14,6 @@ on:
permissions:
contents: read
env:
DOCKER_HUB: seerr/seerr
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
@@ -26,10 +23,10 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
container: node:22.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
@@ -78,7 +75,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
@@ -87,7 +84,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
@@ -114,7 +111,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
@@ -123,7 +120,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
@@ -140,10 +137,10 @@ 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 }}
${{ github.repository }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=develop

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
@@ -105,7 +105,7 @@ jobs:
uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Downloads artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
@@ -151,13 +151,13 @@ 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
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Downloads artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0

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

@@ -11,9 +11,6 @@ on:
permissions:
contents: read
env:
DOCKER_HUB: seerr/seerr
concurrency:
group: preview-${{ github.ref }}
cancel-in-progress: true
@@ -33,7 +30,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 +39,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 +76,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 +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: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -115,10 +112,10 @@ 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 }}
${{ github.repository }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=preview-${{ steps.ver.outputs.version }}

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,10 +141,10 @@ 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 }}
${{ github.repository }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ env.VERSION }}
@@ -173,7 +173,7 @@ jobs:
- name: Resolve manifest digest
id: digests
run: |
DIGEST=$(docker buildx imagetools inspect "${{ env.DOCKER_HUB }}:${{ env.VERSION }}" --format '{{json .Manifest.Digest}}' | tr -d '"')
DIGEST=$(docker buildx imagetools inspect "${{ github.repository }}:${{ env.VERSION }}" --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "IMAGE_DIGEST=$DIGEST" >> $GITHUB_OUTPUT
- name: Also tag :latest (non-pre-release only)
@@ -201,15 +201,15 @@ 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
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.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
@@ -267,7 +267,7 @@ jobs:
VERSION: ${{ github.ref_name }}
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Verify signatures
run: |

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'

57
.github/workflows/support.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'Support requests'
on:
issues:
types: [labeled, unlabeled, reopened]
permissions:
issues: read
jobs:
support:
if: github.event.label.name == 'support' || github.event.action == 'reopened'
runs-on: ubuntu-24.04
concurrency:
group: support-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
issues: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
steps:
- name: Label added, comment and close issue
if: github.event.action == 'labeled' && github.event.label.name == 'support'
shell: bash
env:
BODY: >
:wave: @${{ env.ISSUE_AUTHOR }}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use our support channels
to get help with Seerr.
- [Discord](https://discord.gg/seerr)
run: |
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
retry gh issue comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
retry gh issue close "$NUMBER" -R "$GH_REPO" || true
gh issue lock "$NUMBER" -R "$GH_REPO" -r "off_topic" || true
- name: Reopened or label removed, unlock issue
if: github.event.action == 'unlabeled' && github.event.label.name == 'support'
shell: bash
run: |
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
retry gh issue reopen "$NUMBER" -R "$GH_REPO" || true
gh issue unlock "$NUMBER" -R "$GH_REPO" || true
- name: Remove support label on manual reopen
if: github.event.action == 'reopened'
shell: bash
run: |
gh issue edit "$NUMBER" -R "$GH_REPO" --remove-label "support" || true
gh issue unlock "$NUMBER" -R "$GH_REPO" || true

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

3
.gitignore vendored
View File

@@ -71,6 +71,3 @@ tsconfig.tsbuildinfo
# Config Cache Directory
config/cache
# Docker compose
compose.override.yaml

View File

@@ -2,19 +2,18 @@
.next/
dist/
config/
CHANGELOG.md
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,
},
},
],
};

1216
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -151,9 +151,9 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation
We use [Weblate](https://translate.seerr.dev/projects/seerr/seerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose).
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose).
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/multi-auto.svg" alt="Translation status" /></a>
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
## Migrations

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}
@@ -13,10 +13,7 @@ WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store CI=true pnpm install --prod --frozen-lockfile
FROM base AS build
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
FROM base as build
RUN \
case "${TARGETPLATFORM}" in \
@@ -33,7 +30,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

@@ -7,8 +7,8 @@
</p>
<p align="center">
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
@@ -32,27 +32,15 @@ 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**.
### Packages:
The documentation linked above is for running the **latest Jellyseerr** release.
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
> [!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.
Nix: [Nixpkg](https://search.nixos.org/packages?channel=unstable&show=jellyseerr)
## Preview

View File

@@ -4,8 +4,8 @@ name: seerr-chart
description: Seerr helm chart for Kubernetes
type: application
version: 3.0.0
# renovate: image=ghcr.io/seerr-team/seerr
appVersion: '3.0.0'
# renovate: image=ghcr.io/fallenbagel/jellyseerr
appVersion: '2.7.3'
maintainers:
- name: Seerr Team
url: https://github.com/orgs/seerr-team/people

View File

@@ -1,6 +1,6 @@
# seerr-chart
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square)
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.7.3](https://img.shields.io/badge/AppVersion-2.7.3-informational?style=flat-square)
Seerr helm chart for Kubernetes
@@ -22,13 +22,13 @@ Kubernetes: `>=1.23.0-0`
## Installation
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
## Update Notes
### Updating to 3.0.0
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳 refer to our [Migration guide](https://docs.seerr.dev/migration-guide).
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳.
### Updating to 2.7.0
@@ -70,20 +70,12 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| nodeSelector | object | `{}` | |
| podAnnotations | object | `{}` | |
| podLabels | object | `{}` | |
| podSecurityContext.fsGroup | int | `1000` | |
| podSecurityContext.fsGroupChangePolicy | string | `"OnRootMismatch"` | |
| podSecurityContext | object | `{}` | |
| probes.livenessProbe | object | `{}` | Configure liveness probe |
| probes.readinessProbe | object | `{}` | Configure readiness probe |
| probes.startupProbe | string | `nil` | Configure startup probe |
| resources | object | `{}` | |
| securityContext.allowPrivilegeEscalation | bool | `false` | |
| securityContext.capabilities.drop[0] | string | `"ALL"` | |
| securityContext.privileged | bool | `false` | |
| securityContext.readOnlyRootFilesystem | bool | `false` | |
| securityContext.runAsGroup | int | `1000` | |
| securityContext.runAsNonRoot | bool | `true` | |
| securityContext.runAsUser | int | `1000` | |
| securityContext.seccompProfile.type | string | `"RuntimeDefault"` | |
| securityContext | object | `{}` | |
| service.port | int | `80` | |
| service.type | string | `"ClusterIP"` | |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |

View File

@@ -16,13 +16,13 @@
## Installation
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
## Update Notes
### Updating to 3.0.0
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳 refer to our [Migration guide](https://docs.seerr.dev/migration-guide).
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳.
### Updating to 2.7.0

View File

@@ -50,22 +50,16 @@ serviceAccount:
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
podSecurityContext: {}
# fsGroup: 2000
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: true
privileged: false
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP

View File

@@ -24,9 +24,10 @@ import TabItem from '@theme/TabItem';
```bash
sudo mkdir -p /opt/seerr && cd /opt/seerr
```
2. Clone the Seerr repository and checkout the main branch:
2. Clone the Seerr repository and checkout the develop branch:
```bash
git clone https://github.com/seerr-team/seerr.git .
git clone https://github.com/fallenbagel/jellyseerr.git
cd jellyseerr
git checkout main
```
3. Install the dependencies:
@@ -198,9 +199,9 @@ pm2 status seerr
mkdir C:\seerr
cd C:\seerr
```
2. Clone the Seerr repository and checkout the main branch:
2. Clone the Seerr repository and checkout the develop branch:
```powershell
git clone https://github.com/seerr-team/seerr.git .
git clone https://github.com/fallenbagel/jellyseerr.git .
git checkout main
```
3. Install the dependencies:

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes (Advanced)
description: Install Seerr in Kubernetes
description: Install Jellyseerr in Kubernetes
sidebar_position: 3
---
# Kubernetes
@@ -11,16 +11,17 @@ This method is not recommended for most users. It is intended for advanced users
:::info
All official Seerr charts are cryptographically signed and include a verified [Software Bill of Materials (SBOM)](https://cyclonedx.org/).
To confirm that the chart you are using is authentic and unmodified, please refer to the [Verifying Signed Artifacts](/using-seerr/advanced/verifying-signed-artifacts#verifying-signed-helm-charts) guide.
To confirm that the chart you are using is authentic and unmodified, please refer to the [Verifying Signed Artifacts](/using-jellyseerr/advanced/verifying-signed-artifacts#verifying-signed-helm-charts) guide.
:::
## Installation
```console
helm install seerr oci://ghcr.io/seerr-team/seerr/seerr-chart
```
Helm values can be found in the Seerr repository under [charts/seerr-chart/README.md](https://github.com/seerr-team/seerr/tree/develop/charts/seerr-chart).
Helm values can be found in the Jellyseerr repository under [charts/jellyseerr-chart/README.md](https://github.com/fallenbagel/jellyseerr/tree/develop/charts/jellyseerr-chart).
Verify the signature with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) (replace [tag], with the TAG you want to verify) :
```console
cosign verify ghcr.io/seerr-team/seerr/seerr-chart:[tag] --certificate-identity=https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.actions.githubusercontent.com
cosign verify ghcr.io/seerr-team/seerr/seerr-chart:[tag] --certificate-identity=https://github.com/fallenbagel/jellyseerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.ac
tions.githubusercontent.com
```

View File

@@ -1,168 +0,0 @@
---
title: Migration guide
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Whether you come from Overseerr or Jellyseerr, you don't need to perform any manual migration steps, your instance will automatically be migrated to Seerr.
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.
:::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.
:::
## Docker
Refer to [Seerr Docker Documentation](/getting-started/docker), all of our examples have been updated to reflect the below change.
Changes :
- Renamed all references from `overseerr` or `jellyseerr` to `seerr`.
- The container image reference has been updated.
- 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.
:::info
**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
docker run --rm -v /path/to/appdata/config:/data alpine chown -R 1000:1000 /data
```
This ensures the `node` user (UID 1000) owns the config directory and can read and write to it.
:::
### Unix
Summary of changes :
<Tabs groupId="docker-methods" queryString>
<TabItem value="docker-compose" label="Docker compose">
```yaml {3-6}
---
services:
seerr:
image: ghcr.io/seerr-team/seerr:latest
init: true
container_name: seerr
environment:
- LOG_LEVEL=debug
- TZ=Asia/Tashkent
- PORT=5055 #optional
ports:
- 5055:5055
volumes:
- /path/to/appdata/config:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
start_period: 20s
timeout: 3s
interval: 15s
retries: 3
restart: unless-stopped
```
</TabItem>
<TabItem value="docker-cli" label="Docker CLI">
```bash {2-3,10}
docker run -d \
--name seerr \
--init \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tashkent \
-e PORT=5055 \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
ghcr.io/seerr-team/seerr:latest
```
</TabItem>
</Tabs>
### Windows
Summary of changes :
<Tabs groupId="docker-methods" queryString>
<TabItem value="docker-compose" label="Docker compose">
```yaml {3-6,13,23}
---
services:
seerr:
image: ghcr.io/seerr-team/seerr:latest
init: true
container_name: seerr
environment:
- LOG_LEVEL=debug
- TZ=Asia/Tashkent
ports:
- 5055:5055
volumes:
- seerr-data:/app/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
start_period: 20s
timeout: 3s
interval: 15s
retries: 3
restart: unless-stopped
volumes:
seerr-data:
external: true
```
</TabItem>
<TabItem value="docker-cli" label="Docker CLI">
```bash {2-3,8,10}
docker run -d \
--name seerr \
--init \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tashkent \
-e PORT=5055 \
-p 5055:5055 \
-v seerr-data:/app/config \
--restart unless-stopped \
ghcr.io/seerr-team/seerr:latest
```
</TabItem>
</Tabs>
## Kubernetes
Refer to [Seerr Kubernetes Documentation](/getting-started/kubernetes), all of our examples have been updated to reflect the below change.
Changes :
- All references to `jellyseerr` have been renamed to `seerr` in the manifests.
- The container image reference has been updated.
- The default `securityContext` and `podSecurityContext` have been updated to support running the container without root permissions.
Summary of changes :
<Tabs groupId="kubernetes-values" queryString>
<TabItem value="old" label="Old values">
```yaml
image:
repository: fallenbagel/jellyseerr
podSecurityContext: {}
securityContext: {}
```
</TabItem>
<TabItem value="new" label="New values">
```yaml
image:
repository: seerr-team/seerr
podSecurityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: true
privileged: false
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
```
</TabItem>
</Tabs>

View File

@@ -174,36 +174,4 @@ This can happen if you have a new installation of Jellyfin/Emby or if you have c
This process should restore your admin privileges while preserving your settings.
## Failed to enable web push notifications
### Option 1: You are using Pi-hole
When using Pi-hole, you need to whitelist the proper domains in order for the queries to not be intercepted and blocked by Pi-hole.
If you are using a chromium based browser (eg: Chrome, Brave, Edge...), the domain you need to whitelist is `fcm.googleapis.com`
If you are using Firefox, the domain you need to whitelist is `push.services.mozilla.com`
1. Log into your Pi-hole through the admin interface, then click on Domains situated under GROUP MANAGEMENT.
2. Add the domain corresponding to your browser in the `Domain to be added` field and then click on Add to allowed domains.
3. Now in order for those changes to be used you need to flush your current dns cache.
4. You can do so by using this command line in your Pi-hole terminal:
```bash
pihole restartdns
```
If this command fails (which is unlikely), use this equivalent:
```bash
pihole -f && pihole restartdns
```
5. Then restart your Seerr instance and try to enable the web push notifications again.
### Option 2: You are using Brave browser
Brave is a "De-Googled" browser. So by default or if you refused a prompt in the past, it cuts the access to the FCM (Firebase Cloud Messaging) service, which is mandatory for the web push notifications on Chromium based browsers.
1. Open Brave and paste this address in the url bar: `brave://settings/privacy`
2. Look for the option: "Use Google services for push messaging"
3. Activate this option
4. Relaunch Brave completely
5. You should now see the notifications prompt appearing instead of an error message.
If you still encounter issues, please reach out on our support channels.

View File

@@ -22,4 +22,4 @@ Users can customize their notification preferences in their own user notificatio
## Requesting New Notification Agents
If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/seerr-team/seerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/fallenbagel/jellyseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!

View File

@@ -16,7 +16,7 @@ User notifications are separate from system notifications, and the available not
### Application/API Token
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/seerr-team/seerr/tree/develop/public) when configuring the application.)
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).

View File

@@ -0,0 +1,24 @@
---
title: Welcome to the Jellyseerr Blog
description: The official Jellyseerr blog for release notes, technical updates, and community news.
slug: welcome
authors: [fallenbagel, gauthier-th]
tags: [announcement, jellyseerr, blog]
image: https://raw.githubusercontent.com/fallenbagel/jellyseerr/refs/heads/develop/gen-docs/static/img/logo.svg
hide_table_of_contents: false
---
We are pleased to introduce the official Jellyseerr blog.
This space will serve as the central place for:
- Release announcements
- Updates on new features and improvements
- Technical articles, such as details on our [**DNS caching package**](https://github.com/jellyseerr/dns-caching) and other enhancements
- Community-related news
<!--truncate-->
Our goal is to keep the community informed and provide deeper insights into the ongoing development of Jellyseerr.
Thank you for being part of the Jellyseerr project. More updates will follow soon.

View File

@@ -1,24 +0,0 @@
---
title: Welcome to the Seerr Blog
description: The official Seerr blog for release notes, technical updates, and community news.
slug: welcome
authors: [fallenbagel, gauthier-th]
tags: [announcement, seerr, blog]
image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo.svg
hide_table_of_contents: false
---
We are pleased to introduce the official Seerr blog.
This space will serve as the central place for:
- Release announcements
- Updates on new features and improvements
- Technical articles, such as details on our [**DNS caching package**](https://github.com/seerr/dns-caching) and other enhancements
- Community-related news
<!--truncate-->
Our goal is to keep the community informed and provide deeper insights into the ongoing development of Seerr.
Thank you for being part of the Seerr project. More updates will follow soon.

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

@@ -2,9 +2,10 @@
"name": "seerr",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.24.0",
"packageManager": "pnpm@10.17.1",
"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",
@@ -32,151 +33,156 @@
},
"license": "MIT",
"dependencies": {
"@dr.pogodin/csurf": "^1.16.6",
"@formatjs/intl-displaynames": "6.8.13",
"@dr.pogodin/csurf": "^1.14.1",
"@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.4.6",
"@formatjs/intl-pluralrules": "5.1.10",
"@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",
"@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.56",
"@tanem/react-nprogress": "5.0.30",
"@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2",
"ace-builds": "1.43.4",
"axios": "1.13.3",
"axios-rate-limit": "1.4.0",
"bcrypt": "6.0.0",
"bowser": "2.13.1",
"ace-builds": "1.15.2",
"axios": "1.10.0",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.7",
"copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.6.4",
"country-flag-icons": "1.5.5",
"cronstrue": "2.23.0",
"date-fns": "2.29.3",
"dayjs": "1.11.19",
"dayjs": "1.11.7",
"dns-caching": "^0.2.7",
"email-templates": "12.0.3",
"email-templates": "12.0.1",
"express": "4.21.2",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0",
"express-session": "1.18.2",
"formik": "^2.4.9",
"express-session": "1.17.3",
"formik": "^2.4.6",
"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.11.0",
"plex-api": "5.3.2",
"pug": "3.0.3",
"react": "^18.3.1",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.44.0",
"react-aria": "3.23.0",
"react-dom": "^18.3.1",
"react-intersection-observer": "9.4.3",
"react-intl": "^6.6.8",
"react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2",
"react-select": "5.10.2",
"react-select": "5.7.0",
"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",
"react-use-clipboard": "1.0.9",
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.7.3",
"semver": "7.7.1",
"sharp": "^0.33.4",
"sqlite3": "5.1.7",
"swagger-ui-express": "4.6.2",
"swr": "2.3.8",
"swr": "2.2.5",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.28",
"typeorm": "0.3.12",
"ua-parser-js": "^1.0.35",
"undici": "^7.18.2",
"validator": "^13.15.23",
"web-push": "3.6.7",
"undici": "^7.3.0",
"validator": "^13.15.15",
"web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0",
"winston": "3.19.0",
"winston": "3.8.2",
"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",
"@types/cookie-parser": "1.4.10",
"@types/country-flag-icons": "1.2.2",
"@types/csurf": "1.11.5",
"@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.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.17",
"@types/express-session": "1.18.2",
"@types/lodash": "4.17.21",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "22.10.5",
"@types/node-schedule": "2.1.8",
"@types/nodemailer": "7",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-transition-group": "4.4.12",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.7.1",
"@types/swagger-ui-express": "4.1.8",
"@types/validator": "^13.15.10",
"@types/web-push": "3.6.4",
"@types/xml2js": "0.4.14",
"@types/semver": "7.3.13",
"@types/swagger-ui-express": "4.1.3",
"@types/validator": "^13.15.3",
"@types/web-push": "3.3.2",
"@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",
"baseline-browser-mapping": "^2.8.32",
"commitizen": "4.3.1",
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13",
"commitizen": "4.3.0",
"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",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"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",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.4.5"
"nodemon": "3.1.9",
"postcss": "8.4.31",
"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.1",
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.5"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^10.0.0"
},
"overrides": {
"sqlite3/node-gyp": "8.4.1",
"@types/express-session": "1.17.6"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
@@ -198,15 +204,8 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"bcrypt",
"cypress",
"sharp",
"sqlite3"
],
"overrides": {
"sqlite3>node-gyp": "8.4.1",
"@types/express-session": "1.18.2"
}
"sqlite3",
"bcrypt"
]
}
}

9446
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

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

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

@@ -112,10 +112,6 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string;
}
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
? JellyfinLibraryItemExtended[]
: JellyfinLibraryItem[];
export interface JellyfinItemsReponse {
Items: JellyfinLibraryItemExtended[];
TotalRecordCount: number;
@@ -149,7 +145,7 @@ class JellyfinAPI extends ExternalAPI {
{},
{
headers: {
Authorization: authHeaderVal,
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
@@ -419,22 +415,13 @@ class JellyfinAPI extends ExternalAPI {
}
}
public async getEpisodes<
T extends { includeMediaInfo?: boolean } | undefined = undefined,
>(
public async getEpisodes(
seriesID: string,
seasonID: string,
options?: T
): Promise<EpisodeReturn<T>> {
seasonID: string
): Promise<JellyfinLibraryItem[]> {
try {
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`,
{
params: {
seasonId: seasonID,
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
},
}
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
);
return episodeResponse.Items.filter(

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

@@ -209,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);
}
@@ -346,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

@@ -21,7 +21,6 @@ import {
AfterUpdate,
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
@@ -333,12 +332,6 @@ export class MediaRequest {
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: ${requestBody.mediaType}, TMDB ID: ${requestBody.mediaId}, persisted media id: ${media.id}`
);
}
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
@@ -449,12 +442,6 @@ export class MediaRequest {
await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: TV, TMDB ID: ${requestBody.mediaId}, is4k: ${requestBody.is4k}`
);
}
const request = new MediaRequest({
type: MediaType.TV,
media,
@@ -532,7 +519,6 @@ export class MediaRequest {
eager: true,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'mediaId' })
public media: Media;
@ManyToOne(() => User, (user) => user.requests, {

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

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

@@ -34,8 +34,6 @@ interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
jellyfinMediaId?: string;
imdbId?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
@@ -97,8 +95,6 @@ class BaseScanner<T> {
is4k = false,
mediaAddedAt,
ratingKey,
jellyfinMediaId,
imdbId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -115,11 +111,9 @@ class BaseScanner<T> {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.AVAILABLE
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
@@ -139,21 +133,6 @@ class BaseScanner<T> {
changedExisting = true;
}
if (
jellyfinMediaId &&
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
jellyfinMediaId
) {
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
jellyfinMediaId;
changedExisting = true;
}
if (imdbId && !existing.imdbId) {
existing.imdbId = imdbId;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
@@ -194,20 +173,19 @@ class BaseScanner<T> {
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status =
!is4k && !processing
? MediaStatus.AVAILABLE
: !is4k && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.status4k =
is4k && this.enable4kMovie && !processing
? MediaStatus.AVAILABLE
: is4k && this.enable4kMovie && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.serviceId = !is4k ? serviceId : undefined;
newMedia.serviceId4k = is4k ? serviceId : undefined;
@@ -225,13 +203,6 @@ class BaseScanner<T> {
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
if (jellyfinMediaId) {
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
newMedia.jellyfinMediaId4k =
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
@@ -250,12 +221,11 @@ class BaseScanner<T> {
*/
protected async processShow(
tmdbId: number,
tvdbId: number | undefined,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
jellyfinMediaId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -287,7 +257,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
// We update the rating keys in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
@@ -301,23 +271,6 @@ class BaseScanner<T> {
media.ratingKey4k = ratingKey;
}
if (
media &&
season.episodes > 0 &&
media.jellyfinMediaId !== jellyfinMediaId
) {
media.jellyfinMediaId = jellyfinMediaId;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== jellyfinMediaId
) {
media.jellyfinMediaId4k = jellyfinMediaId;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
@@ -327,17 +280,12 @@ class BaseScanner<T> {
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status;
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
@@ -347,17 +295,12 @@ class BaseScanner<T> {
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k;
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
@@ -366,20 +309,20 @@ class BaseScanner<T> {
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
@@ -486,37 +429,37 @@ class BaseScanner<T> {
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status4k === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status4k === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);
} else {
@@ -548,50 +491,34 @@ class BaseScanner<T> {
)
? ratingKey
: undefined,
jellyfinMediaId: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
jellyfinMediaId4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${title}`);

View File

@@ -1,8 +1,5 @@
import animeList from '@server/api/animelist';
import type {
JellyfinLibraryItem,
JellyfinLibraryItemExtended,
} from '@server/api/jellyfin';
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import { getMetadataProvider } from '@server/api/metadata';
import TheMovieDb from '@server/api/themoviedb';
@@ -11,119 +8,132 @@ import type {
TmdbKeyword,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User';
import type {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash';
interface JellyfinSyncStatus extends StatusBase {
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library;
libraries: Library[];
}
class JellyfinScanner
extends BaseScanner<JellyfinLibraryItem>
implements RunnableScanner<JellyfinSyncStatus>
{
class JellyfinScanner {
private sessionId: string;
private tmdb: TheMovieDb;
private jfClient: JellyfinAPI;
private items: JellyfinLibraryItem[] = [];
private progress = 0;
private libraries: Library[];
private currentLibrary: Library;
private running = false;
private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock();
private processedAnidbSeason: Map<number, Map<number, number>>;
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
super('Jellyfin Sync');
this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
}
private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
tmdbId: number;
imdbId?: string;
metadata: JellyfinLibraryItemExtended;
} | null> {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
if (!metadata?.Id) {
this.log('No Id metadata for this title. Skipping', 'debug', {
jellyfinItemId: jellyfinitem.Id,
});
return null;
}
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
let imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else
if (anidbId && !imdbId && !tmdbId) {
const result = animeList.getFromAnidbId(anidbId);
tmdbId = Number(result?.tmdbId ?? null);
imdbId = result?.imdbId;
}
if (imdbId && !tmdbId) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: imdbId,
});
tmdbId = tmdbMovie.id;
}
if (!tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// With AniDB we can have mixed libraries with movies in a "show" library
// We take the first episode of the first season (the movie) and use it to
// get more information, like the MediaSource
if (anidbId && metadata.Type === 'Series') {
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
(md) => {
return md.IndexNumber === 1;
}
);
if (!season) {
this.log('No season found for anidb movie', 'debug', {
jellyfinitem,
});
return null;
}
const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id,
season.Id
);
if (!episodes[0]) {
this.log('No episode found for anidb movie', 'debug', {
jellyfinitem,
});
return null;
}
metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem,
});
return null;
}
}
return { tmdbId, imdbId, metadata };
return existing;
}
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
try {
const extracted = await this.extractMovieIds(jellyfinitem);
if (!extracted) return;
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
const { tmdbId, imdbId, metadata } = extracted;
try {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media();
if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id,
});
return;
}
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) {
const result = animeList.getFromAnidbId(anidbId);
newMedia.tmdbId = Number(result?.tmdbId ?? null);
newMedia.imdbId = result?.imdbId;
}
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId,
});
newMedia.tmdbId = tmdbMovie.id;
}
if (!newMedia.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// With AniDB we can have mixed libraries with movies in a "show" library
// We take the first episode of the first season (the movie) and use it to
// get more information, like the MediaSource
if (anidbId && metadata.Type === 'Series') {
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
(md) => {
return md.IndexNumber === 1;
}
);
if (!season) {
this.log('No season found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id,
season.Id
);
if (!episodes[0]) {
this.log('No episode found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
}
const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter(
@@ -141,29 +151,93 @@ class JellyfinScanner
});
});
const mediaAddedAt = metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined;
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
if (!metadata) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
await this.processMovie(tmdbId, {
is4k: false,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
const existing = await this.getExisting(
newMedia.tmdbId,
MediaType.MOVIE
);
if (has4k && this.enable4kMovie) {
await this.processMovie(tmdbId, {
is4k: true,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
if (existing) {
let changedExisting = false;
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (!existing.mediaAddedAt && !changedExisting) {
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.jellyfinMediaId !== metadata.Id
) {
existing.jellyfinMediaId = metadata.Id;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.jellyfinMediaId4k !== metadata.Id
) {
existing.jellyfinMediaId4k = metadata.Id;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.Name} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.Name}`
);
}
} else {
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
newMedia.jellyfinMediaId =
hasOtherResolution || (!this.enable4kMovie && has4k)
? metadata.Id
: null;
newMedia.jellyfinMediaId4k =
has4k && this.enable4kMovie ? metadata.Id : null;
await mediaRepository.save(newMedia);
this.log(`Saved ${metadata.Name}`);
}
});
} catch (e) {
this.log(
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
@@ -212,7 +286,9 @@ class JellyfinScanner
return tvShow;
}
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
private async processShow(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null;
try {
@@ -221,7 +297,8 @@ class JellyfinScanner
const metadata = await this.jfClient.getItemData(Id);
if (!metadata?.Id) {
this.log('No Id metadata for this title. Skipping', 'debug', {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id,
});
return;
@@ -238,7 +315,6 @@ class JellyfinScanner
});
}
}
if (!tvShow && metadata.ProviderIds.Tvdb) {
try {
tvShow = await this.getTvShow({
@@ -250,7 +326,6 @@ class JellyfinScanner
});
}
}
let tvdbSeasonFromAnidb: number | undefined;
if (!tvShow && metadata.ProviderIds.AniDB) {
const anidbId = Number(metadata.ProviderIds.AniDB);
@@ -269,49 +344,71 @@ class JellyfinScanner
}
// With AniDB we can have mixed libraries with movies in a "show" library
else if (result?.imdbId || result?.tmdbId) {
await this.processJellyfinMovie(jellyfinitem);
await this.processMovie(jellyfinitem);
return;
}
}
if (tvShow) {
const seasons = tvShow.seasons;
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
await this.asyncLock.dispatch(tvShow.id, async () => {
if (!tvShow) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
const processableSeasons: ProcessableSeason[] = [];
// Lets get the available seasons from Jellyfin
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
const settings = getSettings();
const filteredSeasons = settings.main.enableSpecialEpisodes
? seasons
: seasons.filter((sn) => sn.season_number !== 0);
const newSeasons: Season[] = [];
for (const season of filteredSeasons) {
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
if (tvdbSeasonFromAnidb) {
// In AniDB we don't have the concept of seasons,
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
return (
tvdbSeasonFromAnidb === season.season_number &&
md.IndexNumber === 1
);
} else {
return Number(md.IndexNumber) === season.season_number;
}
});
const currentStandardSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// Check if we found the matching season and it has all the available episodes
if (matchedJellyfinSeason) {
let totalStandard = 0;
let total4k = 0;
for (const season of seasons) {
const JellyfinSeasons = await this.jfClient.getSeasons(Id);
const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
if (tvdbSeasonFromAnidb) {
// In AniDB we don't have the concept of seasons,
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
return (
tvdbSeasonFromAnidb === season.season_number &&
md.IndexNumber === 1
);
} else {
return Number(md.IndexNumber) === season.season_number;
}
});
if (!this.enable4kShow) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes
if (matchedJellyfinSeason) {
// If we have a matched Jellyfin season, get its children metadata so we can check details
const episodes = await this.jfClient.getEpisodes(
Id,
matchedJellyfinSeason.Id
);
//Get count of episodes that are HD and 4K
let totalStandard = 0;
let total4k = 0;
//use for loop to make sure this loop _completes_ in full
//before the next section
for (const episode of episodes) {
let episodeCount = 1;
@@ -324,94 +421,238 @@ class JellyfinScanner
episode.IndexNumberEnd - episode.IndexNumber + 1;
}
totalStandard += episodeCount;
}
} else {
// 4K detection enabled - request media info to check resolution
const episodes = await this.jfClient.getEpisodes(
Id,
matchedJellyfinSeason.Id,
{ includeMediaInfo: true }
);
if (!this.enable4kShow) {
totalStandard += episodeCount;
} else {
const ExtendedEpisodeData = await this.jfClient.getItemData(
episode.Id
);
for (const episode of episodes) {
let episodeCount = 1;
// count number of combined episodes
if (
episode.IndexNumber !== undefined &&
episode.IndexNumberEnd !== undefined
) {
episodeCount =
episode.IndexNumberEnd - episode.IndexNumber + 1;
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') {
if ((MediaStream.Width ?? 0) >= 2000) {
total4k += episodeCount;
} else {
totalStandard += episodeCount;
}
}
});
});
}
const has4k = episode.MediaSources?.some((MediaSource) =>
MediaSource.MediaStreams.some(
(MediaStream) =>
MediaStream.Type === 'Video' &&
(MediaStream.Width ?? 0) > 2000
)
);
const hasStandard = episode.MediaSources?.some((MediaSource) =>
MediaSource.MediaStreams.some(
(MediaStream) =>
MediaStream.Type === 'Video' &&
(MediaStream.Width ?? 0) <= 2000
)
);
// 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;
}
}
// With AniDB we can have multiple shows for one season, so we need to save
// the episode from all the jellyfin entries to get the total
if (tvdbSeasonFromAnidb) {
let show = this.processedAnidbSeason.get(tvShow.id);
// With AniDB we can have multiple shows for one season, so we need to save
// the episode from all the jellyfin entries to get the total
if (tvdbSeasonFromAnidb) {
if (this.processedAnidbSeason.has(tvShow.id)) {
const show = this.processedAnidbSeason.get(tvShow.id)!;
if (show.has(season.season_number)) {
show.set(
season.season_number,
show.get(season.season_number)! + totalStandard
);
if (!show) {
show = new Map([[season.season_number, totalStandard]]);
this.processedAnidbSeason.set(tvShow.id, show);
totalStandard = show.get(season.season_number)!;
} else {
show.set(season.season_number, totalStandard);
}
} else {
this.processedAnidbSeason.set(
tvShow.id,
new Map([[season.season_number, totalStandard]])
);
}
}
if (
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.jellyfinMediaId !== Id
) {
media.jellyfinMediaId = Id;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== Id
) {
media.jellyfinMediaId4k = Id;
}
if (existingSeason) {
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard >= season.episode_count ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
(this.enable4kShow && total4k >= season.episode_count) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
const currentCount = show.get(season.season_number) ?? 0;
const newCount = currentCount + totalStandard;
show.set(season.season_number, newCount);
totalStandard = newCount;
newSeasons.push(
new Season({
seasonNumber: season.season_number,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
);
}
}
}
processableSeasons.push({
seasonNumber: season.season_number,
totalEpisodes: season.episode_count,
episodes: totalStandard,
episodes4k: total4k,
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt) {
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: isAllStandardSeasons ? Id : null,
jellyfinMediaId4k:
isAll4kSeasons && this.enable4kShow ? Id : null,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
}
}
await this.processShow(
tvShow.id,
tvShow.external_ids?.tvdb_id,
processableSeasons,
{
mediaAddedAt: metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined,
jellyfinMediaId: Id,
title: tvShow.name,
}
);
});
} else {
this.log(
`No information found for the show: ${metadata.Name}`,
@@ -427,17 +668,70 @@ class JellyfinScanner
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
}`,
'error',
{ errorMessage: e.message, jellyfinitem }
{
errorMessage: e.message,
jellyfinitem,
}
);
}
}
private async processItem(item: JellyfinLibraryItem): Promise<void> {
if (item.Type === 'Movie') {
await this.processJellyfinMovie(item);
} else if (item.Type === 'Series') {
await this.processJellyfinShow(item);
private async processItems(slicedItems: JellyfinLibraryItem[]) {
this.processedAnidbSeason = new Map();
await Promise.all(
slicedItems.map(async (item) => {
if (item.Type === 'Movie') {
await this.processMovie(item);
} else if (item.Type === 'Series') {
await this.processShow(item);
}
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Jellyfin Sync', ...optional });
}
public async run(): Promise<void> {
@@ -450,9 +744,14 @@ class JellyfinScanner
return;
}
const sessionId = this.startRun();
const sessionId = uuid();
this.sessionId = sessionId;
logger.info('Jellyfin Sync Starting', {
sessionId,
label: 'Jellyfin Sync',
});
try {
this.running = true;
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
@@ -478,11 +777,25 @@ class JellyfinScanner
await animeList.sync();
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
// Reset AniDB season tracking per library
this.processedAnidbSeason = new Map();
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
@@ -502,19 +815,16 @@ class JellyfinScanner
return mediaA.Id === mediaB.Id;
});
await this.loop(this.processItem.bind(this), { sessionId });
await this.loop({ sessionId });
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
// Reset AniDB season tracking per library
this.processedAnidbSeason = new Map();
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.jfClient.getLibraryContents(library.id);
await this.loop(this.processItem.bind(this), { sessionId });
await this.loop({ sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
@@ -522,13 +832,19 @@ class JellyfinScanner
'info'
);
} catch (e) {
this.log('Sync interrupted', 'error', { errorMessage: e.message });
logger.error('Sync interrupted', {
label: 'Jellyfin Sync',
errorMessage: e.message,
});
} finally {
this.endRun(sessionId);
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): JellyfinSyncStatus {
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
@@ -537,6 +853,10 @@ class JellyfinScanner
libraries: this.libraries,
};
}
public cancel(): void {
this.running = false;
}
}
export const jellyfinFullScanner = new JellyfinScanner();

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;

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