Compare commits
59 Commits
fallenbage
...
preview-ty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
801c95bbc5 | ||
|
|
8b41685b31 | ||
|
|
5bd31040c0 | ||
|
|
127a91ca9c | ||
|
|
7d2e24a528 | ||
|
|
ddf347994a | ||
|
|
0f7d29624b | ||
|
|
f627a8e9db | ||
|
|
6031fab3b4 | ||
|
|
e1d3f29383 | ||
|
|
f8f90cb903 | ||
|
|
65844a2f23 | ||
|
|
62755692e9 | ||
|
|
beba2ea099 | ||
|
|
88b2e7843f | ||
|
|
dbd5935ade | ||
|
|
bb2120c14d | ||
|
|
c9037f77e6 | ||
|
|
48631db989 | ||
|
|
ac7c2983d3 | ||
|
|
767dc529e8 | ||
|
|
448a25e2a4 | ||
|
|
3f35b8c886 | ||
|
|
d0f029b46e | ||
|
|
e0a81038cd | ||
|
|
4ab919360a | ||
|
|
adbcf80333 | ||
|
|
f91a26befe | ||
|
|
0c95b5ec91 | ||
|
|
193d4dc668 | ||
|
|
d0c9afc16e | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 | ||
|
|
3ee69663dc | ||
|
|
539d49879d | ||
|
|
15356dfe49 | ||
|
|
1f04eeb040 | ||
|
|
e3028c21f2 | ||
|
|
9d8b343790 | ||
|
|
f4fe16608a | ||
|
|
d660a540da | ||
|
|
48ef2984e5 | ||
|
|
c5fc31c352 | ||
|
|
c3b9ea6ce4 | ||
|
|
b66b36186a | ||
|
|
fb5196bdec | ||
|
|
bde322de8e | ||
|
|
af083a3cd5 | ||
|
|
f4af6ed5f4 | ||
|
|
267450a297 | ||
|
|
939000fbe4 | ||
|
|
08800c7cf3 | ||
|
|
2fe72530a2 | ||
|
|
6dcae346f9 | ||
|
|
597858785e | ||
|
|
91aa7d143e | ||
|
|
41bcbfe9a4 | ||
|
|
7d4b2853dc |
@@ -16,6 +16,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn', // disable the rule for now to replicate previous behavior
|
||||
'@typescript-eslint/camelcase': 0,
|
||||
'@typescript-eslint/no-use-before-define': 0,
|
||||
'jsx-a11y/no-noninteractive-tabindex': 0,
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this bug has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this feature has already been requested?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
35
.github/PULL_REQUEST_TEMPLATE.md
vendored
35
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,14 +1,33 @@
|
||||
#### Description
|
||||
<!--
|
||||
Please read contributing guide before submitting
|
||||
your pull request. Please fill in each section below to help us better prioritize your pull request. Thanks!
|
||||
-->
|
||||
|
||||
#### Screenshot (if UI-related)
|
||||
## Description
|
||||
|
||||
#### To-Dos
|
||||
<!--- 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. -->
|
||||
|
||||
- [ ] Disclosed any use of AI (see our [policy](../CONTRIBUTING.md#ai-assistance-notice))
|
||||
- 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.
|
||||
- [ ] Successful build `pnpm build`
|
||||
- [ ] Translation keys `pnpm i18n:extract`
|
||||
- [ ] Database migration (if required)
|
||||
|
||||
#### Issues Fixed or Closed
|
||||
|
||||
- Fixes #XXXX
|
||||
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -14,6 +14,9 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOCKER_HUB: seerr/seerr
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -23,10 +26,10 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
container: node:22.20.0-alpine3.22@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af
|
||||
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -75,7 +78,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -84,7 +87,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -111,7 +114,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -120,7 +123,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -137,10 +140,10 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
${{ env.DOCKER_HUB }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=develop
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -37,20 +37,20 @@ jobs:
|
||||
language: [actions, javascript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
4
.github/workflows/cypress.yml
vendored
4
.github/workflows/cypress.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run: pnpm exec cypress install
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2
|
||||
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
|
||||
with:
|
||||
install: false
|
||||
build: pnpm cypress:build
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
name: Build Docusaurus
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/docs-link-check.yml
vendored
4
.github/workflows/docs-link-check.yml
vendored
@@ -36,13 +36,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Lychee link checker
|
||||
uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
|
||||
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
|
||||
with:
|
||||
fail: false
|
||||
args: >-
|
||||
|
||||
10
.github/workflows/helm.yml
vendored
10
.github/workflows/helm.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Downloads artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
|
||||
4
.github/workflows/lint-helm-charts.yml
vendored
4
.github/workflows/lint-helm-charts.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
|
||||
- name: Ensure documentation is updated
|
||||
uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c
|
||||
|
||||
15
.github/workflows/preview.yml
vendored
15
.github/workflows/preview.yml
vendored
@@ -11,6 +11,9 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOCKER_HUB: seerr/seerr
|
||||
|
||||
concurrency:
|
||||
group: preview-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -30,7 +33,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -39,7 +42,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Derive preview version from tag
|
||||
id: ver
|
||||
@@ -76,7 +79,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -85,7 +88,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -112,10 +115,10 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
${{ env.DOCKER_HUB }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=preview-${{ steps.ver.outputs.version }}
|
||||
|
||||
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
@@ -27,14 +27,14 @@ jobs:
|
||||
release_body: ${{ steps.git-cliff.outputs.content }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate changelog
|
||||
id: git-cliff
|
||||
uses: orhun/git-cliff-action@d77b37db2e3f7398432d34b72a12aa3e2ba87e51 # v4.6.0
|
||||
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
|
||||
with:
|
||||
config: .github/cliff.toml
|
||||
args: -vv --current
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
needs: changelog
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Warm cache [${{ matrix.platform }}]
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -141,10 +141,10 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
${{ env.DOCKER_HUB }}
|
||||
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 "${{ github.repository }}:${{ env.VERSION }}" --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
DIGEST=$(docker buildx imagetools inspect "${{ env.DOCKER_HUB }}:${{ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Install Trivy
|
||||
uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 # v0.2.4
|
||||
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
|
||||
@@ -25,19 +25,19 @@ jobs:
|
||||
if: github.actor == 'renovate[bot]'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: 2138788
|
||||
private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: 'AI-generated pull requests'
|
||||
name: 'Seerr Labeller'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
ai-generated-support:
|
||||
if: github.event.label.name == 'ai-generated' || github.event.action == 'reopened'
|
||||
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')))
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: support-${{ github.event.pull_request.number }}
|
||||
group: ai-generated-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -43,7 +46,7 @@ jobs:
|
||||
retry gh pr close "$NUMBER" -R "$GH_REPO" || true
|
||||
gh pr lock "$NUMBER" -R "$GH_REPO" -r "spam" || true
|
||||
|
||||
- name: Reopened or label removed, unlock pull request
|
||||
- name: Label removed, reopen and unlock pull request
|
||||
if: github.event.action == 'unlabeled' && github.event.label.name == 'ai-generated'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -57,3 +60,52 @@ 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
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
any-of-labels: "pending author's response"
|
||||
exempt-issue-labels: 'confirmed'
|
||||
|
||||
57
.github/workflows/support.yml
vendored
57
.github/workflows/support.yml
vendored
@@ -1,57 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
2
.github/workflows/test-docs-deploy.yml
vendored
2
.github/workflows/test-docs-deploy.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/trivy-scan.yml
vendored
4
.github/workflows/trivy-scan.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -56,6 +56,6 @@ jobs:
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload SARIF to code scanning
|
||||
uses: github/codeql-action/upload-sarif@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: trivy.sarif
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -71,3 +71,6 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# Config Cache Directory
|
||||
config/cache
|
||||
|
||||
# Docker compose
|
||||
compose.override.yaml
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
.next/
|
||||
dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
.github
|
||||
.vscode
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
public/
|
||||
!public/sw.js
|
||||
docs/
|
||||
!/public/
|
||||
/public/*
|
||||
!/public/sw.js
|
||||
public/*
|
||||
!public/sw.js
|
||||
|
||||
# helm charts
|
||||
**/charts
|
||||
|
||||
# Prettier breaks GitHub alert syntax in markdown
|
||||
*.md
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [require('./merged-prettier-plugin.js')],
|
||||
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
overrides: [
|
||||
@@ -27,5 +27,11 @@ module.exports = {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'public/offline.html',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
1216
CHANGELOG.md
1216
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -151,9 +151,9 @@ When adding new UI text, please try to adhere to the following guidelines:
|
||||
|
||||
## Translation
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/multi-auto.svg" alt="Translation status" /></a>
|
||||
|
||||
## Migrations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7 AS base
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG TARGETPLATFORM
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
@@ -13,7 +13,10 @@ 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
|
||||
FROM base AS build
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
@@ -30,7 +33,7 @@ RUN pnpm build
|
||||
|
||||
RUN rm -rf .next/cache
|
||||
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG COMMIT_TAG
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
24
README.md
24
README.md
@@ -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/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://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://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,15 +32,27 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
### Packages:
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
Nix: [Nixpkg](https://search.nixos.org/packages?channel=unstable&show=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.
|
||||
|
||||
## Preview
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ name: seerr-chart
|
||||
description: Seerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 3.0.0
|
||||
# renovate: image=ghcr.io/fallenbagel/jellyseerr
|
||||
appVersion: '2.7.3'
|
||||
# renovate: image=ghcr.io/seerr-team/seerr
|
||||
appVersion: '3.0.0'
|
||||
maintainers:
|
||||
- name: Seerr Team
|
||||
url: https://github.com/orgs/seerr-team/people
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# seerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Seerr helm chart for Kubernetes
|
||||
|
||||
@@ -22,13 +22,13 @@ Kubernetes: `>=1.23.0-0`
|
||||
|
||||
## Installation
|
||||
|
||||
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
|
||||
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
|
||||
|
||||
## Update Notes
|
||||
|
||||
### Updating to 3.0.0
|
||||
|
||||
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳.
|
||||
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳 refer to our [Migration guide](https://docs.seerr.dev/migration-guide).
|
||||
|
||||
### Updating to 2.7.0
|
||||
|
||||
@@ -70,12 +70,20 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
|
||||
| nodeSelector | object | `{}` | |
|
||||
| podAnnotations | object | `{}` | |
|
||||
| podLabels | object | `{}` | |
|
||||
| podSecurityContext | object | `{}` | |
|
||||
| podSecurityContext.fsGroup | int | `1000` | |
|
||||
| podSecurityContext.fsGroupChangePolicy | string | `"OnRootMismatch"` | |
|
||||
| probes.livenessProbe | object | `{}` | Configure liveness probe |
|
||||
| probes.readinessProbe | object | `{}` | Configure readiness probe |
|
||||
| probes.startupProbe | string | `nil` | Configure startup probe |
|
||||
| resources | object | `{}` | |
|
||||
| securityContext | 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"` | |
|
||||
| service.port | int | `80` | |
|
||||
| service.type | string | `"ClusterIP"` | |
|
||||
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
## Installation
|
||||
|
||||
Refer to [https://docs.seerr.dev/getting-started/kubernetes](Seerr kubernetes documentation)
|
||||
Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes)
|
||||
|
||||
## Update Notes
|
||||
|
||||
### Updating to 3.0.0
|
||||
|
||||
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳.
|
||||
Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳 refer to our [Migration guide](https://docs.seerr.dev/migration-guide).
|
||||
|
||||
### Updating to 2.7.0
|
||||
|
||||
|
||||
@@ -50,16 +50,22 @@ serviceAccount:
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: true
|
||||
privileged: false
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
@@ -24,10 +24,9 @@ import TabItem from '@theme/TabItem';
|
||||
```bash
|
||||
sudo mkdir -p /opt/seerr && cd /opt/seerr
|
||||
```
|
||||
2. Clone the Seerr repository and checkout the develop branch:
|
||||
2. Clone the Seerr repository and checkout the main branch:
|
||||
```bash
|
||||
git clone https://github.com/fallenbagel/jellyseerr.git
|
||||
cd jellyseerr
|
||||
git clone https://github.com/seerr-team/seerr.git .
|
||||
git checkout main
|
||||
```
|
||||
3. Install the dependencies:
|
||||
@@ -199,9 +198,9 @@ pm2 status seerr
|
||||
mkdir C:\seerr
|
||||
cd C:\seerr
|
||||
```
|
||||
2. Clone the Seerr repository and checkout the develop branch:
|
||||
2. Clone the Seerr repository and checkout the main branch:
|
||||
```powershell
|
||||
git clone https://github.com/fallenbagel/jellyseerr.git .
|
||||
git clone https://github.com/seerr-team/seerr.git .
|
||||
git checkout main
|
||||
```
|
||||
3. Install the dependencies:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Kubernetes (Advanced)
|
||||
description: Install Jellyseerr in Kubernetes
|
||||
description: Install Seerr in Kubernetes
|
||||
sidebar_position: 3
|
||||
---
|
||||
# Kubernetes
|
||||
@@ -11,17 +11,16 @@ 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-jellyseerr/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-seerr/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 Jellyseerr repository under [charts/jellyseerr-chart/README.md](https://github.com/fallenbagel/jellyseerr/tree/develop/charts/jellyseerr-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).
|
||||
|
||||
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/fallenbagel/jellyseerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.ac
|
||||
tions.githubusercontent.com
|
||||
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
|
||||
```
|
||||
|
||||
168
docs/migration-guide.mdx
Normal file
168
docs/migration-guide.mdx
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
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>
|
||||
@@ -174,4 +174,36 @@ 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.
|
||||
|
||||
@@ -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/fallenbagel/jellyseerr/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/seerr-team/seerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
|
||||
|
||||
@@ -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/fallenbagel/jellyseerr/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/seerr-team/seerr/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).
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
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.
|
||||
24
gen-docs/blog/2025-09-29-introducing-seerr-blog.md
Normal file
24
gen-docs/blog/2025-09-29-introducing-seerr-blog.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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.
|
||||
@@ -60,12 +60,12 @@
|
||||
|
||||
.table-of-contents__link--active,
|
||||
a:not(
|
||||
.card,
|
||||
.menu__link,
|
||||
.menu__link--sublist,
|
||||
.menu__link--sublist-item,
|
||||
.table-of-contents__link
|
||||
) {
|
||||
.card,
|
||||
.menu__link,
|
||||
.menu__link--sublist,
|
||||
.menu__link--sublist-item,
|
||||
.table-of-contents__link
|
||||
) {
|
||||
/* color: #793ae8; */
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/* eslint-disable */
|
||||
const tailwind = require('prettier-plugin-tailwindcss');
|
||||
const organizeImports = require('prettier-plugin-organize-imports');
|
||||
|
||||
const combinedFormatter = {
|
||||
...tailwind,
|
||||
parsers: {
|
||||
...tailwind.parsers,
|
||||
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
...tailwind.parsers[key],
|
||||
preprocess(code, options) {
|
||||
return organizeImports.parsers[key].preprocess(code, options);
|
||||
},
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = combinedFormatter;
|
||||
163
package.json
163
package.json
@@ -2,10 +2,9 @@
|
||||
"name": "seerr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||
"build:next": "next build",
|
||||
@@ -17,7 +16,7 @@
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||
"format": "prettier --loglevel warn --write --cache .",
|
||||
"format": "prettier --log-level warn --write --cache .",
|
||||
"format:check": "prettier --check --cache .",
|
||||
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
|
||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||
@@ -33,156 +32,151 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dr.pogodin/csurf": "^1.14.1",
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@dr.pogodin/csurf": "^1.16.6",
|
||||
"@formatjs/intl-displaynames": "6.8.13",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
"@formatjs/intl-pluralrules": "5.4.6",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"@tanem/react-nprogress": "5.0.56",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/wink-jaro-distance": "^2.0.2",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.10.0",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"ace-builds": "1.43.4",
|
||||
"axios": "1.13.3",
|
||||
"axios-rate-limit": "1.4.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"bowser": "2.13.1",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.7",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"country-flag-icons": "1.6.4",
|
||||
"cronstrue": "2.23.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dayjs": "1.11.19",
|
||||
"dns-caching": "^0.2.7",
|
||||
"email-templates": "12.0.1",
|
||||
"email-templates": "12.0.3",
|
||||
"express": "4.21.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
"formik": "^2.4.6",
|
||||
"express-session": "1.18.2",
|
||||
"formik": "^2.4.9",
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"mime": "3",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.35",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.11.0",
|
||||
"plex-api": "5.3.2",
|
||||
"nodemailer": "7.0.12",
|
||||
"openpgp": "6.3.0",
|
||||
"pg": "8.17.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.23.0",
|
||||
"react-aria": "3.44.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.7.0",
|
||||
"react-select": "5.10.2",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.7.1",
|
||||
"semver": "7.7.3",
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"swr": "2.3.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.12",
|
||||
"typeorm": "0.3.28",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"undici": "^7.3.0",
|
||||
"validator": "^13.15.15",
|
||||
"web-push": "3.5.0",
|
||||
"undici": "^7.18.2",
|
||||
"validator": "^13.15.23",
|
||||
"web-push": "3.6.7",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.8.2",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"xml2js": "0.5.0",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.24.2"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@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",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/lodash": "4.17.21",
|
||||
"@types/mime": "3",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"@types/nodemailer": "7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@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/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/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"commitizen": "4.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||
"@typescript-eslint/parser": "7.18.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "14.1.0",
|
||||
"cypress": "14.5.4",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
@@ -204,8 +198,15 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sqlite3",
|
||||
"bcrypt"
|
||||
]
|
||||
"@swc/core",
|
||||
"bcrypt",
|
||||
"cypress",
|
||||
"sharp",
|
||||
"sqlite3"
|
||||
],
|
||||
"overrides": {
|
||||
"sqlite3>node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.18.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9374
pnpm-lock.yaml
generated
9374
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const typeormPath = path.resolve('node_modules/typeorm');
|
||||
|
||||
if (fs.existsSync(typeormPath)) {
|
||||
process.stdout.write('> Installing typeorm@0.3.11 for Windows\n');
|
||||
execSync('pnpm add typeorm@0.3.11', { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
@@ -13,6 +13,7 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
export interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
rateLimit?: {
|
||||
maxRPS: number;
|
||||
maxRequests: number;
|
||||
@@ -32,6 +33,7 @@ class ExternalAPI {
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
params,
|
||||
timeout: options.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
|
||||
@@ -57,7 +57,7 @@ interface GithubCommit {
|
||||
sha: string;
|
||||
url: string;
|
||||
html_url: string;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
DateCreated?: string;
|
||||
}
|
||||
|
||||
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
|
||||
? JellyfinLibraryItemExtended[]
|
||||
: JellyfinLibraryItem[];
|
||||
|
||||
export interface JellyfinItemsReponse {
|
||||
Items: JellyfinLibraryItemExtended[];
|
||||
TotalRecordCount: number;
|
||||
@@ -145,7 +149,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': authHeaderVal,
|
||||
Authorization: authHeaderVal,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
@@ -415,13 +419,22 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async getEpisodes(
|
||||
public async getEpisodes<
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined,
|
||||
>(
|
||||
seriesID: string,
|
||||
seasonID: string
|
||||
): Promise<JellyfinLibraryItem[]> {
|
||||
seasonID: string,
|
||||
options?: T
|
||||
): Promise<EpisodeReturn<T>> {
|
||||
try {
|
||||
const episodeResponse = await this.get<any>(
|
||||
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||
`/Shows/${seriesID}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
seasonId: seasonID,
|
||||
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return episodeResponse.Items.filter(
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { Library, PlexSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import NodePlexAPI from 'plex-api';
|
||||
|
||||
interface PlexStatusResponse {
|
||||
MediaContainer: {
|
||||
machineIdentifier: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -84,9 +91,7 @@ interface PlexMetadataResponse {
|
||||
};
|
||||
}
|
||||
|
||||
class PlexAPI {
|
||||
private plexClient: NodePlexAPI;
|
||||
|
||||
class PlexAPI extends ExternalAPI {
|
||||
constructor({
|
||||
plexToken,
|
||||
plexSettings,
|
||||
@@ -97,48 +102,33 @@ class PlexAPI {
|
||||
timeout?: number;
|
||||
}) {
|
||||
const settings = getSettings();
|
||||
let settingsPlex: PlexSettings | undefined;
|
||||
plexSettings
|
||||
? (settingsPlex = plexSettings)
|
||||
: (settingsPlex = getSettings().plex);
|
||||
const settingsPlex = plexSettings ?? settings.plex;
|
||||
|
||||
this.plexClient = new NodePlexAPI({
|
||||
hostname: settingsPlex.ip,
|
||||
port: settingsPlex.port,
|
||||
https: settingsPlex.useSsl,
|
||||
timeout: timeout,
|
||||
token: plexToken ?? undefined,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi,
|
||||
cb: (err?: string, token?: string) => void
|
||||
) => {
|
||||
if (!plexToken) {
|
||||
return cb('Plex Token not found!');
|
||||
}
|
||||
cb(undefined, plexToken);
|
||||
const protocol = settingsPlex.useSsl ? 'https' : 'http';
|
||||
const baseUrl = `${protocol}://${settingsPlex.ip}:${settingsPlex.port}`;
|
||||
|
||||
super(
|
||||
baseUrl,
|
||||
{},
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
'X-Plex-Token': plexToken ?? '',
|
||||
'X-Plex-Client-Identifier': settings.clientId,
|
||||
'X-Plex-Product': 'Seerr',
|
||||
'X-Plex-Device-Name': 'Seerr',
|
||||
'X-Plex-Platform': 'Seerr',
|
||||
},
|
||||
},
|
||||
// requestOptions: {
|
||||
// includeChildren: 1,
|
||||
// },
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Seerr',
|
||||
deviceName: 'Seerr',
|
||||
platform: 'Seerr',
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getStatus() {
|
||||
return await this.plexClient.query('/');
|
||||
public async getStatus(): Promise<PlexStatusResponse> {
|
||||
return await this.get('/');
|
||||
}
|
||||
|
||||
public async getLibraries(): Promise<PlexLibrary[]> {
|
||||
const response = await this.plexClient.query<PlexLibrariesResponse>(
|
||||
'/library/sections'
|
||||
);
|
||||
const response = await this.get<PlexLibrariesResponse>('/library/sections');
|
||||
|
||||
return response.MediaContainer.Directory;
|
||||
}
|
||||
@@ -187,13 +177,15 @@ class PlexAPI {
|
||||
id: string,
|
||||
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
|
||||
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?includeGuids=1`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `${offset}`,
|
||||
'X-Plex-Container-Size': `${size}`,
|
||||
},
|
||||
});
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?includeGuids=1`,
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Container-Start': `${offset}`,
|
||||
'X-Plex-Container-Size': `${size}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
totalSize: response.MediaContainer.totalSize,
|
||||
@@ -205,7 +197,7 @@ class PlexAPI {
|
||||
key: string,
|
||||
options: { includeChildren?: boolean } = {}
|
||||
): Promise<PlexMetadata> {
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
const response = await this.get<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}${
|
||||
options.includeChildren ? '?includeChildren=1' : ''
|
||||
}`
|
||||
@@ -215,7 +207,7 @@ class PlexAPI {
|
||||
}
|
||||
|
||||
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
const response = await this.get<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}/children`
|
||||
);
|
||||
|
||||
@@ -229,15 +221,17 @@ class PlexAPI {
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
},
|
||||
});
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Container-Start': '0',
|
||||
'X-Plex-Container-Size': '500',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const mapSounds = (sounds: {
|
||||
({
|
||||
name,
|
||||
description,
|
||||
} as PushoverSound)
|
||||
}) as PushoverSound
|
||||
);
|
||||
|
||||
class PushoverAPI extends ExternalAPI {
|
||||
|
||||
@@ -157,8 +157,8 @@ class RottenTomatoes extends ExternalAPI {
|
||||
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||
? 'Certified Fresh'
|
||||
: movie.rottenTomatoes.criticsScore >= 60
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||
audienceRating:
|
||||
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
|
||||
@@ -209,6 +209,34 @@ class SonarrAPI extends ServarrBase<{
|
||||
series: newSeriesResponse.data,
|
||||
});
|
||||
|
||||
try {
|
||||
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
|
||||
const episodeIdsToMonitor = episodes
|
||||
.filter(
|
||||
(ep) =>
|
||||
options.seasons.includes(ep.seasonNumber) && !ep.monitored
|
||||
)
|
||||
.map((ep) => ep.id);
|
||||
|
||||
if (episodeIdsToMonitor.length > 0) {
|
||||
logger.debug(
|
||||
'Re-monitoring unmonitored episodes for requested seasons.',
|
||||
{
|
||||
label: 'Sonarr',
|
||||
seriesId: newSeriesResponse.data.id,
|
||||
episodeCount: episodeIdsToMonitor.length,
|
||||
}
|
||||
);
|
||||
await this.monitorEpisodes(episodeIdsToMonitor);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to re-monitor episodes', {
|
||||
label: 'Sonarr',
|
||||
errorMessage: e.message,
|
||||
seriesId: newSeriesResponse.data.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.searchNow) {
|
||||
this.searchSeries(newSeriesResponse.data.id);
|
||||
}
|
||||
@@ -318,6 +346,38 @@ class SonarrAPI extends ServarrBase<{
|
||||
}
|
||||
}
|
||||
|
||||
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
|
||||
try {
|
||||
const response = await this.axios.get<EpisodeResult[]>('/episode', {
|
||||
params: { seriesId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
logger.error('Failed to retrieve episodes', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
seriesId,
|
||||
});
|
||||
throw new Error('Failed to get episodes');
|
||||
}
|
||||
}
|
||||
|
||||
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
|
||||
try {
|
||||
await this.axios.put('/episode/monitor', {
|
||||
episodeIds,
|
||||
monitored: true,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to monitor episodes', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
episodeIds,
|
||||
});
|
||||
throw new Error('Failed to monitor episodes');
|
||||
}
|
||||
}
|
||||
|
||||
private buildSeasonList(
|
||||
seasons: number[],
|
||||
existingSeasons?: SonarrSeason[]
|
||||
|
||||
@@ -269,8 +269,8 @@ class TautulliAPI {
|
||||
recordA.grandparent_rating_key && recordB.grandparent_rating_key
|
||||
? recordA.grandparent_rating_key === recordB.grandparent_rating_key
|
||||
: recordA.parent_rating_key && recordB.parent_rating_key
|
||||
? recordA.parent_rating_key === recordB.parent_rating_key
|
||||
: recordA.rating_key === recordB.rating_key
|
||||
? recordA.parent_rating_key === recordB.parent_rating_key
|
||||
: recordA.rating_key === recordB.rating_key
|
||||
);
|
||||
|
||||
start += take;
|
||||
|
||||
@@ -536,8 +536,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'primary_release_date.gte':
|
||||
@@ -630,8 +630,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
|
||||
@@ -392,8 +392,10 @@ export interface TmdbPersonCombinedCredits {
|
||||
crew: TmdbPersonCreditCrew[];
|
||||
}
|
||||
|
||||
export interface TmdbSeasonWithEpisodes
|
||||
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
|
||||
export interface TmdbSeasonWithEpisodes extends Omit<
|
||||
TmdbTvSeasonResult,
|
||||
'episode_count'
|
||||
> {
|
||||
episodes: TmdbTvEpisodeResult[];
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AfterUpdate,
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -332,6 +333,12 @@ export class MediaRequest {
|
||||
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||
await mediaRepository.save(media);
|
||||
|
||||
if (!media.id) {
|
||||
throw new Error(
|
||||
`Failed to save media before creating request. Media type: ${requestBody.mediaType}, TMDB ID: ${requestBody.mediaId}, persisted media id: ${media.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
media,
|
||||
@@ -442,6 +449,12 @@ export class MediaRequest {
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
if (!media.id) {
|
||||
throw new Error(
|
||||
`Failed to save media before creating request. Media type: TV, TMDB ID: ${requestBody.mediaId}, is4k: ${requestBody.is4k}`
|
||||
);
|
||||
}
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
media,
|
||||
@@ -519,6 +532,7 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'mediaId' })
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { Permission, hasPermission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { AfterDate } from '@server/utils/dateHelpers';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { AfterDate } from '@server/utils/dateHelpers';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
@@ -271,7 +271,7 @@ export class User {
|
||||
});
|
||||
|
||||
const movieQuotaLimit = !canBypass
|
||||
? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
|
||||
? (this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit)
|
||||
: 0;
|
||||
const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
|
||||
|
||||
@@ -295,7 +295,7 @@ export class User {
|
||||
: 0;
|
||||
|
||||
const tvQuotaLimit = !canBypass
|
||||
? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
|
||||
? (this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit)
|
||||
: 0;
|
||||
const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -97,7 +97,10 @@ app
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
await createCustomProxyAgent(
|
||||
settings.network.proxy,
|
||||
settings.network.forceIpv4First
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
|
||||
@@ -300,7 +300,6 @@ class AvailabilitySync {
|
||||
// Sonarr finds that season, we will change the final seasons value
|
||||
// to true.
|
||||
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
@@ -311,48 +310,7 @@ class AvailabilitySync {
|
||||
filteredSeasonsMap.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
// non-4k
|
||||
const finalSeasons: Map<number, boolean> = new Map();
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
plexSeasonsMap.forEach((value, key) => {
|
||||
finalSeasons.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
jellyfinSeasonsMap.forEach((value, key) => {
|
||||
finalSeasons.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap.forEach((value, key) => {
|
||||
if (!finalSeasons.has(key)) {
|
||||
finalSeasons.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
@@ -363,44 +321,32 @@ class AvailabilitySync {
|
||||
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
// 4k
|
||||
const finalSeasons4k: Map<number, boolean> = new Map();
|
||||
let finalSeasons: Map<number, boolean>;
|
||||
let finalSeasons4k: Map<number, boolean>;
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
plexSeasonsMap4k.forEach((value, key) => {
|
||||
finalSeasons4k.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
jellyfinSeasonsMap4k.forEach((value, key) => {
|
||||
finalSeasons4k.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...plexSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...plexSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
} else {
|
||||
// Jellyfin/Emby
|
||||
finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...jellyfinSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...jellyfinSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -567,8 +513,8 @@ class AvailabilitySync {
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -642,8 +588,8 @@ class AvailabilitySync {
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -666,6 +612,13 @@ class AvailabilitySync {
|
||||
): Promise<boolean> {
|
||||
let existsInRadarr = false;
|
||||
|
||||
const hasSameServerInBothModes = this.radarrServers.some((a) =>
|
||||
this.radarrServers.some(
|
||||
(b) =>
|
||||
a.is4k !== b.is4k && a.hostname === b.hostname && a.port === b.port
|
||||
)
|
||||
);
|
||||
|
||||
// Check for availability in all of the available radarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.radarrServers.filter(
|
||||
@@ -696,7 +649,14 @@ class AvailabilitySync {
|
||||
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
|
||||
const is4kMovie =
|
||||
resolution?.length === 2 && Number(resolution[0]) >= 2000;
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
|
||||
if (hasSameServerInBothModes && resolution?.length === 2) {
|
||||
// Same server in both modes then use resolution to distinguish
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
} else {
|
||||
// One server type and if file exists, count it
|
||||
existsInRadarr = true;
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
@@ -712,6 +672,8 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr) break;
|
||||
}
|
||||
|
||||
return existsInRadarr;
|
||||
@@ -870,6 +832,50 @@ class AvailabilitySync {
|
||||
this.plexSeasonsCache[ratingKey4k] =
|
||||
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||
}
|
||||
|
||||
if (plexMedia) {
|
||||
if (ratingKey === ratingKey4k) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
plexMedia &&
|
||||
media.mediaType === 'movie' &&
|
||||
!plexMedia.Media?.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
)
|
||||
) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
|
||||
if (plexMedia && media.mediaType === 'tv') {
|
||||
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||
if (cachedSeasons?.length) {
|
||||
let has4kInAnySeason = false;
|
||||
for (const season of cachedSeasons) {
|
||||
try {
|
||||
const episodes = await this.plexClient?.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
const has4kEpisode = episodes?.some((episode) =>
|
||||
episode.Media?.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
)
|
||||
);
|
||||
if (has4kEpisode) {
|
||||
has4kInAnySeason = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch episodes for a season, continue checking other seasons
|
||||
}
|
||||
}
|
||||
if (!has4kInAnySeason) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plexMedia) {
|
||||
@@ -993,8 +999,8 @@ class AvailabilitySync {
|
||||
existsInJellyfin = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404' || '500')) {
|
||||
existsInJellyfin = false;
|
||||
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||
existsInJellyfin = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
|
||||
@@ -2,12 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -209,8 +209,8 @@ class DiscordAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import type { EmailOptions } from 'email-templates';
|
||||
import path from 'path';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NotificationAgentGotify } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NotificationAgentNtfy } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushover } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -45,7 +45,17 @@ class PushoverAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
return true;
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getImagePayload(
|
||||
@@ -148,8 +158,8 @@ class PushoverAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
const url_title = url
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NotificationAgentSlack } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -183,8 +183,8 @@ class SlackAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
hasNotificationType,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -133,8 +133,8 @@ class TelegramAgent
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { Notification, hasNotificationType } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -122,7 +122,7 @@ class WebhookAgent
|
||||
`{{${keymapKey}}}`,
|
||||
typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) ?? ''
|
||||
: (get(payload, keymapValue) ?? '')
|
||||
);
|
||||
});
|
||||
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
|
||||
@@ -186,8 +186,8 @@ class WebhookAgent
|
||||
type === Notification.TEST_NOTIFICATION
|
||||
? 'test'
|
||||
: typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) || 'test';
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) || 'test';
|
||||
webhookUrl = webhookUrl.replace(
|
||||
new RegExp(`{{${keymapKey}}}`, 'g'),
|
||||
encodeURIComponent(variableValue)
|
||||
|
||||
@@ -5,7 +5,7 @@ import MediaRequest from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import webpush from 'web-push';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
@@ -24,6 +24,15 @@ interface PushNotificationPayload {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface WebPushError extends Error {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
body?: string | unknown;
|
||||
response?: {
|
||||
body?: string | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
extends BaseAgent<NotificationAgentConfig>
|
||||
implements NotificationAgent
|
||||
@@ -119,8 +128,8 @@ class WebPushAgent
|
||||
const actionUrl = payload.issue
|
||||
? `/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
const actionUrlTitle = actionUrl
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'}`
|
||||
@@ -188,19 +197,30 @@ class WebPushAgent
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
const webPushError = e as WebPushError;
|
||||
const statusCode = webPushError.statusCode || webPushError.status;
|
||||
const errorMessage = webPushError.message || String(e);
|
||||
|
||||
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||
|
||||
logger.error(
|
||||
'Error sending web push notification; removing subscription',
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
}
|
||||
);
|
||||
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -111,9 +115,11 @@ class BaseScanner<T> {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
existing[is4k ? 'status4k' : 'status'] = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
existing[is4k ? 'status4k' : 'status'] = !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.PROCESSING;
|
||||
if (mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
@@ -133,6 +139,21 @@ class BaseScanner<T> {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jellyfinMediaId &&
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||
jellyfinMediaId
|
||||
) {
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||
jellyfinMediaId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (imdbId && !existing.imdbId) {
|
||||
existing.imdbId = imdbId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
@@ -173,19 +194,20 @@ class BaseScanner<T> {
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
newMedia.imdbId = imdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: !is4k && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
is4k && this.enable4kMovie && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: is4k && this.enable4kMovie && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.serviceId = !is4k ? serviceId : undefined;
|
||||
newMedia.serviceId4k = is4k ? serviceId : undefined;
|
||||
@@ -203,6 +225,13 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||
}
|
||||
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
@@ -221,11 +250,12 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -257,7 +287,7 @@ class BaseScanner<T> {
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -271,6 +301,23 @@ class BaseScanner<T> {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes > 0 &&
|
||||
media.jellyfinMediaId !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
@@ -280,12 +327,17 @@ class BaseScanner<T> {
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status;
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: !season.is4kOverride &&
|
||||
!season.processing &&
|
||||
season.episodes === 0 &&
|
||||
existingSeason.status === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: existingSeason.status;
|
||||
|
||||
// Same thing here, except we only do updates if 4k is enabled
|
||||
existingSeason.status4k =
|
||||
@@ -295,12 +347,17 @@ class BaseScanner<T> {
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status4k;
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: season.is4kOverride &&
|
||||
!season.processing &&
|
||||
season.episodes4k === 0 &&
|
||||
existingSeason.status4k === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
@@ -309,20 +366,20 @@ class BaseScanner<T> {
|
||||
season.totalEpisodes === season.episodes && season.episodes > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow &&
|
||||
season.totalEpisodes === season.episodes4k &&
|
||||
season.episodes4k > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -429,37 +486,37 @@ class BaseScanner<T> {
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status4k === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.seasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status4k === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
} else {
|
||||
@@ -491,34 +548,50 @@ class BaseScanner<T> {
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
jellyfinMediaId: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
jellyfinMediaId4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
newSeasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${title}`);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import type {
|
||||
JellyfinLibraryItem,
|
||||
JellyfinLibraryItemExtended,
|
||||
} from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
@@ -8,132 +11,119 @@ 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';
|
||||
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
interface JellyfinSyncStatus extends StatusBase {
|
||||
currentLibrary: Library;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
class JellyfinScanner {
|
||||
private sessionId: string;
|
||||
private tmdb: TheMovieDb;
|
||||
class JellyfinScanner
|
||||
extends BaseScanner<JellyfinLibraryItem>
|
||||
implements RunnableScanner<JellyfinSyncStatus>
|
||||
{
|
||||
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 } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
|
||||
super('Jellyfin Sync');
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
|
||||
tmdbId: number;
|
||||
imdbId?: string;
|
||||
metadata: JellyfinLibraryItemExtended;
|
||||
} | null> {
|
||||
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
if (!metadata?.Id) {
|
||||
this.log('No Id metadata for this title. Skipping', 'debug', {
|
||||
jellyfinItemId: jellyfinitem.Id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return existing;
|
||||
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 };
|
||||
}
|
||||
|
||||
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
|
||||
try {
|
||||
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
const newMedia = new Media();
|
||||
const extracted = await this.extractMovieIds(jellyfinitem);
|
||||
if (!extracted) return;
|
||||
|
||||
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 { tmdbId, imdbId, metadata } = extracted;
|
||||
|
||||
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.filter(
|
||||
@@ -151,93 +141,29 @@ class JellyfinScanner {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
const mediaAddedAt = metadata.DateCreated
|
||||
? new Date(metadata.DateCreated)
|
||||
: undefined;
|
||||
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
);
|
||||
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: false,
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
if (has4k && this.enable4kMovie) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: true,
|
||||
mediaAddedAt,
|
||||
jellyfinMediaId: metadata.Id,
|
||||
imdbId,
|
||||
title: metadata.Name,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
||||
@@ -286,9 +212,7 @@ class JellyfinScanner {
|
||||
return tvShow;
|
||||
}
|
||||
|
||||
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
try {
|
||||
@@ -297,8 +221,7 @@ class JellyfinScanner {
|
||||
const metadata = await this.jfClient.getItemData(Id);
|
||||
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Jellyfin Sync',
|
||||
this.log('No Id metadata for this title. Skipping', 'debug', {
|
||||
jellyfinItemId: jellyfinitem.Id,
|
||||
});
|
||||
return;
|
||||
@@ -315,6 +238,7 @@ class JellyfinScanner {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
||||
try {
|
||||
tvShow = await this.getTvShow({
|
||||
@@ -326,6 +250,7 @@ class JellyfinScanner {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let tvdbSeasonFromAnidb: number | undefined;
|
||||
if (!tvShow && metadata.ProviderIds.AniDB) {
|
||||
const anidbId = Number(metadata.ProviderIds.AniDB);
|
||||
@@ -344,71 +269,49 @@ class JellyfinScanner {
|
||||
}
|
||||
// With AniDB we can have mixed libraries with movies in a "show" library
|
||||
else if (result?.imdbId || result?.tmdbId) {
|
||||
await this.processMovie(jellyfinitem);
|
||||
await this.processJellyfinMovie(jellyfinitem);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShow) {
|
||||
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 seasons = tvShow.seasons;
|
||||
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
|
||||
// Lets get the available seasons from Jellyfin
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
const settings = getSettings();
|
||||
const filteredSeasons = settings.main.enableSpecialEpisodes
|
||||
? seasons
|
||||
: seasons.filter((sn) => sn.season_number !== 0);
|
||||
|
||||
const currentStandardSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
const current4kSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (matchedJellyfinSeason) {
|
||||
let totalStandard = 0;
|
||||
let total4k = 0;
|
||||
|
||||
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
|
||||
if (!this.enable4kShow) {
|
||||
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;
|
||||
|
||||
@@ -421,238 +324,94 @@ class JellyfinScanner {
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
if (!this.enable4kShow) {
|
||||
totalStandard += episodeCount;
|
||||
} else {
|
||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
||||
episode.Id
|
||||
);
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
} else {
|
||||
// 4K detection enabled - request media info to check resolution
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id,
|
||||
{ includeMediaInfo: true }
|
||||
);
|
||||
|
||||
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
total4k += episodeCount;
|
||||
} else {
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove extras season. We dont count it for determining availability
|
||||
const filteredSeasons = tvShow.seasons.filter(
|
||||
(season) => season.season_number !== 0
|
||||
);
|
||||
// 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);
|
||||
|
||||
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 (!show) {
|
||||
show = new Map([[season.season_number, totalStandard]]);
|
||||
this.processedAnidbSeason.set(tvShow.id, show);
|
||||
} else {
|
||||
const currentCount = show.get(season.season_number) ?? 0;
|
||||
const newCount = currentCount + totalStandard;
|
||||
show.set(season.season_number, newCount);
|
||||
totalStandard = newCount;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.season_number,
|
||||
totalEpisodes: season.episode_count,
|
||||
episodes: totalStandard,
|
||||
episodes4k: total4k,
|
||||
});
|
||||
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}`,
|
||||
@@ -668,70 +427,17 @@ class JellyfinScanner {
|
||||
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
|
||||
}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
jellyfinitem,
|
||||
}
|
||||
{ errorMessage: e.message, jellyfinitem }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.');
|
||||
private async processItem(item: JellyfinLibraryItem): Promise<void> {
|
||||
if (item.Type === 'Movie') {
|
||||
await this.processJellyfinMovie(item);
|
||||
} else if (item.Type === 'Series') {
|
||||
await this.processJellyfinShow(item);
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -744,14 +450,9 @@ class JellyfinScanner {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
logger.info('Jellyfin Sync Starting', {
|
||||
sessionId,
|
||||
label: 'Jellyfin Sync',
|
||||
});
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.running = true;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
where: { id: 1 },
|
||||
@@ -777,25 +478,11 @@ 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'
|
||||
@@ -815,16 +502,19 @@ class JellyfinScanner {
|
||||
return mediaA.Id === mediaB.Id;
|
||||
});
|
||||
|
||||
await this.loop({ sessionId });
|
||||
await this.loop(this.processItem.bind(this), { 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({ sessionId });
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
@@ -832,19 +522,13 @@ class JellyfinScanner {
|
||||
'info'
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Sync interrupted', {
|
||||
label: 'Jellyfin Sync',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
this.log('Sync interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
public status(): JellyfinSyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
@@ -853,10 +537,6 @@ class JellyfinScanner {
|
||||
libraries: this.libraries,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const jellyfinFullScanner = new JellyfinScanner();
|
||||
|
||||
@@ -59,12 +59,11 @@ searchProviders.push({
|
||||
|
||||
const successfulResponses = responses.filter(
|
||||
(r) => r.status === 'fulfilled'
|
||||
) as
|
||||
| (
|
||||
| PromiseFulfilledResult<TmdbMovieDetails>
|
||||
| PromiseFulfilledResult<TmdbTvDetails>
|
||||
| PromiseFulfilledResult<TmdbPersonDetails>
|
||||
)[];
|
||||
) as (
|
||||
| PromiseFulfilledResult<TmdbMovieDetails>
|
||||
| PromiseFulfilledResult<TmdbTvDetails>
|
||||
| PromiseFulfilledResult<TmdbPersonDetails>
|
||||
)[];
|
||||
|
||||
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
|
||||
|
||||
@@ -185,11 +184,10 @@ searchProviders.push({
|
||||
|
||||
const successfulResponses = responses.filter(
|
||||
(r) => r.status === 'fulfilled'
|
||||
) as
|
||||
| (
|
||||
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||
)[];
|
||||
) as (
|
||||
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||
)[];
|
||||
|
||||
const results: (TmdbMovieResult | TmdbTvResult)[] = [];
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find({
|
||||
select: ['id'],
|
||||
});
|
||||
const users = await userRepository.find();
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const radarrTags = await radarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
const userTag = radarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await radarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const sonarrTags = await sonarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
const userTag = sonarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await sonarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramMessageThreadId1734786596045
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddTelegramMessageThreadId1734786596045 implements MigrationInterface {
|
||||
name = 'AddTelegramMessageThreadId1734786596045';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserAvatarCacheFields1743107707465
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserAvatarCacheFields1743107707465 implements MigrationInterface {
|
||||
name = 'AddUserAvatarCacheFields1743107707465';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_push_subscription"
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM "user_push_subscription"
|
||||
GROUP BY "endpoint", "userId"
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserRequestDeleteCascades1608219049304
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface {
|
||||
name = 'AddUserRequestDeleteCascades1608219049304';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLastSeasonChangeMedia1608477467935
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface {
|
||||
name = 'AddLastSeasonChangeMedia1608477467935';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ForceDropImdbUniqueConstraint1608477467935
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface {
|
||||
name = 'ForceDropImdbUniqueConstraint1608477467936';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveTmdbIdUniqueConstraint1609236552057
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface {
|
||||
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaAddedFieldToMedia1610522845513
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface {
|
||||
name = 'AddMediaAddedFieldToMedia1610522845513';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SonarrRadarrSyncServiceFields1611757511674
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface {
|
||||
name = 'SonarrRadarrSyncServiceFields1611757511674';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface {
|
||||
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateUserSettingsRegions1613955393450
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface {
|
||||
name = 'UpdateUserSettingsRegions1613955393450';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramSettingsToUserSettings1614334195680
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface {
|
||||
name = 'AddTelegramSettingsToUserSettings1614334195680';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateTagsFieldonMediaRequest1617624225464
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface {
|
||||
name = 'CreateTagsFieldonMediaRequest1617624225464';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserSettingsNotificationAgentsField1617730837489
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface {
|
||||
name = 'AddUserSettingsNotificationAgentsField1617730837489';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserPushSubscriptions1618912653565
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface {
|
||||
name = 'CreateUserPushSubscriptions1618912653565';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserSettingsNotificationTypes1619339817343
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface {
|
||||
name = 'AddUserSettingsNotificationTypes1619339817343';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPushbulletPushoverUserSettings1635079863457
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface {
|
||||
name = 'AddPushbulletPushoverUserSettings1635079863457';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWatchlistSyncUserSetting1660632269368
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddWatchlistSyncUserSetting1660632269368 implements MigrationInterface {
|
||||
name = 'AddWatchlistSyncUserSetting1660632269368';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaRequestIsAutoRequestedField1660714479373
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddMediaRequestIsAutoRequestedField1660714479373 implements MigrationInterface {
|
||||
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserSettingsStreamingRegion1727907530757
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserSettingsStreamingRegion1727907530757 implements MigrationInterface {
|
||||
name = 'AddUserSettingsStreamingRegion1727907530757';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramMessageThreadId1734287582736
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddTelegramMessageThreadId1734287582736 implements MigrationInterface {
|
||||
name = 'AddTelegramMessageThreadId1734287582736';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserAvatarCacheFields1743107645301
|
||||
implements MigrationInterface
|
||||
{
|
||||
export class AddUserAvatarCacheFields1743107645301 implements MigrationInterface {
|
||||
name = 'AddUserAvatarCacheFields1743107645301';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_push_subscription"
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM "user_push_subscription"
|
||||
GROUP BY "endpoint", "userId"
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { Video } from './Movie';
|
||||
import type {
|
||||
Cast,
|
||||
Crew,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
mapVideos,
|
||||
mapWatchProviders,
|
||||
} from './common';
|
||||
import type { Video } from './Movie';
|
||||
|
||||
interface Episode {
|
||||
id: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user