Compare commits
35 Commits
preview-mu
...
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 |
@@ -16,6 +16,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn', // disable the rule for now to replicate previous behavior
|
||||
'@typescript-eslint/camelcase': 0,
|
||||
'@typescript-eslint/no-use-before-define': 0,
|
||||
'jsx-a11y/no-noninteractive-tabindex': 0,
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this bug has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this feature has already been requested?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
container: node:22.20.0-alpine3.22@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af
|
||||
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -37,20 +37,20 @@ jobs:
|
||||
language: [actions, javascript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
4
.github/workflows/cypress.yml
vendored
4
.github/workflows/cypress.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run: pnpm exec cypress install
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2
|
||||
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
|
||||
with:
|
||||
install: false
|
||||
build: pnpm cypress:build
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
name: Build Docusaurus
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/docs-link-check.yml
vendored
4
.github/workflows/docs-link-check.yml
vendored
@@ -36,13 +36,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Lychee link checker
|
||||
uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
|
||||
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
|
||||
with:
|
||||
fail: false
|
||||
args: >-
|
||||
|
||||
6
.github/workflows/helm.yml
vendored
6
.github/workflows/helm.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/lint-helm-charts.yml
vendored
4
.github/workflows/lint-helm-charts.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
|
||||
- name: Ensure documentation is updated
|
||||
uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c
|
||||
|
||||
10
.github/workflows/preview.yml
vendored
10
.github/workflows/preview.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Derive preview version from tag
|
||||
id: ver
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
|
||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -27,14 +27,14 @@ jobs:
|
||||
release_body: ${{ steps.git-cliff.outputs.content }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate changelog
|
||||
id: git-cliff
|
||||
uses: orhun/git-cliff-action@d77b37db2e3f7398432d34b72a12aa3e2ba87e51 # v4.6.0
|
||||
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
|
||||
with:
|
||||
config: .github/cliff.toml
|
||||
args: -vv --current
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
needs: changelog
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Warm cache [${{ matrix.platform }}]
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_HUB }}
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
COSIGN_YES: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Install Trivy
|
||||
uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 # v0.2.4
|
||||
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
@@ -25,19 +25,19 @@ jobs:
|
||||
if: github.actor == 'renovate[bot]'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: 2138788
|
||||
private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
any-of-labels: "pending author's response"
|
||||
exempt-issue-labels: 'confirmed'
|
||||
|
||||
2
.github/workflows/test-docs-deploy.yml
vendored
2
.github/workflows/test-docs-deploy.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/trivy-scan.yml
vendored
4
.github/workflows/trivy-scan.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -56,6 +56,6 @@ jobs:
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload SARIF to code scanning
|
||||
uses: github/codeql-action/upload-sarif@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: trivy.sarif
|
||||
|
||||
@@ -4,15 +4,17 @@ dist/
|
||||
config/
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
.github
|
||||
.vscode
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
public/
|
||||
!public/sw.js
|
||||
docs/
|
||||
!/public/
|
||||
/public/*
|
||||
!/public/sw.js
|
||||
public/*
|
||||
!public/sw.js
|
||||
|
||||
# helm charts
|
||||
**/charts
|
||||
|
||||
# Prettier breaks GitHub alert syntax in markdown
|
||||
*.md
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [require('./merged-prettier-plugin.js')],
|
||||
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
overrides: [
|
||||
@@ -27,5 +27,11 @@ module.exports = {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'public/offline.html',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7 AS base
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG TARGETPLATFORM
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
@@ -33,7 +33,7 @@ RUN pnpm build
|
||||
|
||||
RUN rm -rf .next/cache
|
||||
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG COMMIT_TAG
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
20
README.md
20
README.md
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
> [!WARNING]
|
||||
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||
|
||||
Instead, follow the dedicated migration guide (with `:develop` tag):
|
||||
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||
|
||||
> [!CAUTION]
|
||||
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
|
||||
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
|
||||
|
||||
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
@@ -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;
|
||||
@@ -11,8 +11,6 @@ module.exports = {
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: 'artworks.thetvdb.com' },
|
||||
{ hostname: 'plex.tv' },
|
||||
{ hostname: 'archive.org' },
|
||||
{ hostname: 'r2.theaudiodb.com' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
95
package.json
95
package.json
@@ -5,7 +5,6 @@
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||
"build:next": "next build",
|
||||
@@ -17,7 +16,7 @@
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||
"format": "prettier --loglevel warn --write --cache .",
|
||||
"format": "prettier --log-level warn --write --cache .",
|
||||
"format:check": "prettier --check --cache .",
|
||||
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
|
||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||
@@ -38,18 +37,18 @@
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.4.6",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.56",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/wink-jaro-distance": "^2.0.2",
|
||||
"ace-builds": "1.43.4",
|
||||
"axios": "1.13.2",
|
||||
"axios": "1.13.3",
|
||||
"axios-rate-limit": "1.4.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"bowser": "2.13.1",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.7",
|
||||
@@ -59,8 +58,7 @@
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.19",
|
||||
"dns-caching": "^0.2.7",
|
||||
"dompurify": "^3.2.4",
|
||||
"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",
|
||||
@@ -69,17 +67,15 @@
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"mime": "3",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.35",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.16.3",
|
||||
"plex-api": "5.3.2",
|
||||
"nodemailer": "7.0.12",
|
||||
"openpgp": "6.3.0",
|
||||
"pg": "8.17.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
@@ -92,7 +88,6 @@
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.10.2",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
@@ -103,41 +98,39 @@
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.3.7",
|
||||
"swr": "2.3.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.12",
|
||||
"typeorm": "0.3.28",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"undici": "^7.16.0",
|
||||
"undici": "^7.18.2",
|
||||
"validator": "^13.15.23",
|
||||
"web-push": "3.6.7",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.18.3",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"xml2js": "0.5.0",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.24.2"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/country-flag-icons": "1.2.2",
|
||||
"@types/csurf": "1.11.5",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@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.8",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/nodemailer": "7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
@@ -146,20 +139,20 @@
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.22",
|
||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||
"@typescript-eslint/parser": "7.18.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "14.1.0",
|
||||
"cypress": "14.5.4",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
@@ -170,24 +163,20 @@
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "3.1.11",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"tailwindcss": "3.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "3.4.19",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "4.9.5"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
"pnpm": "^10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.18.2"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
@@ -214,6 +203,10 @@
|
||||
"cypress",
|
||||
"sharp",
|
||||
"sqlite3"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"sqlite3>node-gyp": "8.4.1",
|
||||
"@types/express-session": "1.18.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6659
pnpm-lock.yaml
generated
6659
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' });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
1000
seerr-api.yml
1000
seerr-api.yml
File diff suppressed because it is too large
Load Diff
@@ -1,211 +0,0 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import { In } from 'typeorm';
|
||||
import type { CoverArtResponse } from './interfaces';
|
||||
|
||||
class CoverArtArchive extends ExternalAPI {
|
||||
private readonly CACHE_TTL = 43200;
|
||||
private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'https://coverartarchive.org',
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('covertartarchive').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private isMetadataStale(metadata: MetadataAlbum | null): boolean {
|
||||
if (!metadata) return true;
|
||||
return Date.now() - metadata.updatedAt.getTime() > this.STALE_THRESHOLD;
|
||||
}
|
||||
|
||||
private createEmptyResponse(id: string): CoverArtResponse {
|
||||
return { images: [], release: `/release/${id}` };
|
||||
}
|
||||
|
||||
private createCachedResponse(url: string, id: string): CoverArtResponse {
|
||||
return {
|
||||
images: [
|
||||
{
|
||||
approved: true,
|
||||
front: true,
|
||||
id: 0,
|
||||
thumbnails: { 250: url },
|
||||
},
|
||||
],
|
||||
release: `/release/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
public async getCoverArtFromCache(
|
||||
id: string
|
||||
): Promise<string | null | undefined> {
|
||||
try {
|
||||
const metadata = await getRepository(MetadataAlbum).findOne({
|
||||
where: { mbAlbumId: id },
|
||||
select: ['caaUrl'],
|
||||
});
|
||||
return metadata?.caaUrl;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch cover art from cache', {
|
||||
label: 'CoverArtArchive',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async getCoverArt(id: string): Promise<CoverArtResponse> {
|
||||
try {
|
||||
const metadata = await getRepository(MetadataAlbum).findOne({
|
||||
where: { mbAlbumId: id },
|
||||
select: ['caaUrl', 'updatedAt'],
|
||||
});
|
||||
|
||||
if (metadata?.caaUrl) {
|
||||
return this.createCachedResponse(metadata.caaUrl, id);
|
||||
}
|
||||
|
||||
if (metadata && !this.isMetadataStale(metadata)) {
|
||||
return this.createEmptyResponse(id);
|
||||
}
|
||||
|
||||
return await this.fetchCoverArt(id);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get cover art', {
|
||||
label: 'CoverArtArchive',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return this.createEmptyResponse(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCoverArt(id: string): Promise<CoverArtResponse> {
|
||||
try {
|
||||
const data = await this.get<CoverArtResponse>(
|
||||
`/release-group/${id}`,
|
||||
undefined,
|
||||
this.CACHE_TTL
|
||||
);
|
||||
|
||||
const releaseMBID = data.release.split('/').pop();
|
||||
|
||||
data.images = data.images.map((image) => {
|
||||
const fullUrl = `https://archive.org/download/mbid-${releaseMBID}/mbid-${releaseMBID}-${image.id}_thumb250.jpg`;
|
||||
|
||||
if (image.front) {
|
||||
getRepository(MetadataAlbum)
|
||||
.upsert(
|
||||
{ mbAlbumId: id, caaUrl: fullUrl },
|
||||
{ conflictPaths: ['mbAlbumId'] }
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error('Failed to save album metadata', {
|
||||
label: 'CoverArtArchive',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
approved: image.approved,
|
||||
front: image.front,
|
||||
id: image.id,
|
||||
thumbnails: { 250: fullUrl },
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
await getRepository(MetadataAlbum).upsert(
|
||||
{ mbAlbumId: id, caaUrl: null },
|
||||
{ conflictPaths: ['mbAlbumId'] }
|
||||
);
|
||||
return this.createEmptyResponse(id);
|
||||
}
|
||||
}
|
||||
|
||||
public async batchGetCoverArt(
|
||||
ids: string[]
|
||||
): Promise<Record<string, string | null>> {
|
||||
if (!ids.length) return {};
|
||||
|
||||
const validIds = ids.filter(
|
||||
(id) =>
|
||||
typeof id === 'string' &&
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
if (!validIds.length) return {};
|
||||
|
||||
const resultsMap = new Map<string, string | null>();
|
||||
const idsToFetch: string[] = [];
|
||||
|
||||
const metadataRepository = getRepository(MetadataAlbum);
|
||||
const existingMetadata = await metadataRepository.find({
|
||||
where: { mbAlbumId: In(validIds) },
|
||||
select: ['mbAlbumId', 'caaUrl', 'updatedAt'],
|
||||
});
|
||||
|
||||
const metadataMap = new Map(
|
||||
existingMetadata.map((metadata) => [metadata.mbAlbumId, metadata])
|
||||
);
|
||||
|
||||
for (const id of validIds) {
|
||||
const metadata = metadataMap.get(id);
|
||||
|
||||
if (metadata?.caaUrl) {
|
||||
resultsMap.set(id, metadata.caaUrl);
|
||||
} else if (metadata && !this.isMetadataStale(metadata)) {
|
||||
resultsMap.set(id, null);
|
||||
} else {
|
||||
idsToFetch.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToFetch.length > 0) {
|
||||
const batchPromises = idsToFetch.map((id) =>
|
||||
this.fetchCoverArt(id)
|
||||
.then((response) => {
|
||||
const frontImage = response.images.find((img) => img.front);
|
||||
resultsMap.set(id, frontImage?.thumbnails?.[250] || null);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to fetch cover art', {
|
||||
label: 'CoverArtArchive',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
resultsMap.set(id, null);
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(batchPromises);
|
||||
}
|
||||
|
||||
const results: Record<string, string | null> = {};
|
||||
for (const [key, value] of resultsMap.entries()) {
|
||||
results[key] = value;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default CoverArtArchive;
|
||||
@@ -1,15 +0,0 @@
|
||||
interface CoverArtThumbnails {
|
||||
250: string;
|
||||
}
|
||||
|
||||
interface CoverArtImage {
|
||||
approved: boolean;
|
||||
front: boolean;
|
||||
id: number;
|
||||
thumbnails: CoverArtThumbnails;
|
||||
}
|
||||
|
||||
export interface CoverArtResponse {
|
||||
images: CoverArtImage[];
|
||||
release: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ interface JellyfinMediaFolder {
|
||||
}
|
||||
|
||||
export interface JellyfinLibrary {
|
||||
type: 'show' | 'movie' | 'music';
|
||||
type: 'show' | 'movie';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
@@ -66,13 +66,7 @@ export interface JellyfinLibraryItem {
|
||||
Name: string;
|
||||
Id: string;
|
||||
HasSubtitles: boolean;
|
||||
Type:
|
||||
| 'Movie'
|
||||
| 'Episode'
|
||||
| 'Season'
|
||||
| 'Series'
|
||||
| 'MusicAlbum'
|
||||
| 'MusicArtist';
|
||||
Type: 'Movie' | 'Episode' | 'Season' | 'Series';
|
||||
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
|
||||
SeriesName?: string;
|
||||
SeriesId?: string;
|
||||
@@ -82,8 +76,6 @@ export interface JellyfinLibraryItem {
|
||||
IndexNumberEnd?: number;
|
||||
ParentIndexNumber?: number;
|
||||
MediaType: string;
|
||||
AlbumId?: string;
|
||||
ArtistId?: string;
|
||||
}
|
||||
|
||||
export interface JellyfinMediaStream {
|
||||
@@ -112,9 +104,6 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
Imdb?: string;
|
||||
Tvdb?: string;
|
||||
AniDB?: string;
|
||||
MusicBrainzReleaseGroup: string | undefined;
|
||||
MusicBrainzAlbum?: string;
|
||||
MusicBrainzArtistId?: string;
|
||||
};
|
||||
MediaSources?: JellyfinMediaSource[];
|
||||
Width?: number;
|
||||
@@ -319,7 +308,13 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
|
||||
const excludedTypes = ['books', 'musicvideos', 'homevideos', 'boxsets'];
|
||||
const excludedTypes = [
|
||||
'music',
|
||||
'books',
|
||||
'musicvideos',
|
||||
'homevideos',
|
||||
'boxsets',
|
||||
];
|
||||
|
||||
return mediaFolders
|
||||
.filter((Item: JellyfinMediaFolder) => {
|
||||
@@ -332,12 +327,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return <JellyfinLibrary>{
|
||||
key: Item.Id,
|
||||
title: Item.Name,
|
||||
type:
|
||||
Item.CollectionType === 'movies'
|
||||
? 'movie'
|
||||
: Item.CollectionType === 'tvshows'
|
||||
? 'show'
|
||||
: 'music',
|
||||
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
|
||||
agent: 'jellyfin',
|
||||
};
|
||||
});
|
||||
@@ -346,7 +336,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const libraryItemsResponse = await this.get<any>(
|
||||
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,MusicAlbum,MusicArtist,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||
);
|
||||
|
||||
return libraryItemsResponse.Items.filter(
|
||||
@@ -430,7 +420,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
public async getEpisodes<
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined,
|
||||
>(
|
||||
seriesID: string,
|
||||
seasonID: string,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import type {
|
||||
LbAlbumDetails,
|
||||
LbArtistDetails,
|
||||
LbFreshReleasesResponse,
|
||||
LbTopAlbumsResponse,
|
||||
LbTopArtistsResponse,
|
||||
} from './interfaces';
|
||||
|
||||
class ListenBrainzAPI extends ExternalAPI {
|
||||
constructor() {
|
||||
super(
|
||||
'https://api.listenbrainz.org/1',
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('listenbrainz').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 25,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getAlbum(mbid: string): Promise<LbAlbumDetails> {
|
||||
try {
|
||||
return await this.post<LbAlbumDetails>(
|
||||
`/album/${mbid}`,
|
||||
{},
|
||||
{
|
||||
baseURL: 'https://listenbrainz.org',
|
||||
},
|
||||
43200
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[ListenBrainz] Failed to fetch album details: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getArtist(mbid: string): Promise<LbArtistDetails> {
|
||||
try {
|
||||
return await this.post<LbArtistDetails>(
|
||||
`/artist/${mbid}`,
|
||||
{},
|
||||
{
|
||||
baseURL: 'https://listenbrainz.org',
|
||||
},
|
||||
43200
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[ListenBrainz] Failed to fetch artist details: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTopAlbums({
|
||||
offset = 0,
|
||||
range = 'month',
|
||||
count = 20,
|
||||
}: {
|
||||
offset?: number;
|
||||
range?: string;
|
||||
count?: number;
|
||||
}): Promise<LbTopAlbumsResponse> {
|
||||
return this.get<LbTopAlbumsResponse>(
|
||||
'/stats/sitewide/release-groups',
|
||||
{
|
||||
params: {
|
||||
offset: offset.toString(),
|
||||
range,
|
||||
count: count.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
}
|
||||
|
||||
public async getTopArtists({
|
||||
offset = 0,
|
||||
range = 'month',
|
||||
count = 20,
|
||||
}: {
|
||||
offset?: number;
|
||||
range?: string;
|
||||
count?: number;
|
||||
}): Promise<LbTopArtistsResponse> {
|
||||
return this.get<LbTopArtistsResponse>(
|
||||
'/stats/sitewide/artists',
|
||||
{
|
||||
params: {
|
||||
offset: offset.toString(),
|
||||
range,
|
||||
count: count.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
}
|
||||
|
||||
public async getFreshReleases({
|
||||
days = 7,
|
||||
sort = 'release_date',
|
||||
offset = 0,
|
||||
count = 20,
|
||||
}: {
|
||||
days?: number;
|
||||
sort?: string;
|
||||
offset?: number;
|
||||
count?: number;
|
||||
} = {}): Promise<LbFreshReleasesResponse> {
|
||||
return this.get<LbFreshReleasesResponse>(
|
||||
'/explore/fresh-releases',
|
||||
{
|
||||
params: {
|
||||
days: days.toString(),
|
||||
sort,
|
||||
offset: offset.toString(),
|
||||
count: count.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ListenBrainzAPI;
|
||||
@@ -1,243 +0,0 @@
|
||||
export interface LbSimilarArtistResponse {
|
||||
artist_mbid: string;
|
||||
name: string;
|
||||
comment: string;
|
||||
type: string | null;
|
||||
gender: string | null;
|
||||
score: number;
|
||||
reference_mbid: string;
|
||||
}
|
||||
|
||||
export interface LbReleaseGroup {
|
||||
artist_mbids: string[];
|
||||
artist_name: string;
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
listen_count: number;
|
||||
release_group_mbid: string;
|
||||
release_group_name: string;
|
||||
}
|
||||
|
||||
export interface LbTopAlbumsResponse {
|
||||
payload: {
|
||||
count: number;
|
||||
from_ts: number;
|
||||
last_updated: number;
|
||||
offset: number;
|
||||
range: string;
|
||||
release_groups: LbReleaseGroup[];
|
||||
to_ts: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LbArtist {
|
||||
artist_credit_name: string;
|
||||
artist_mbid: string;
|
||||
join_phrase: string;
|
||||
}
|
||||
|
||||
export interface LbTrack {
|
||||
artist_mbids: string[];
|
||||
artists: LbArtist[];
|
||||
length: number;
|
||||
name: string;
|
||||
position: number;
|
||||
recording_mbid: string;
|
||||
total_listen_count: number;
|
||||
total_user_count: number;
|
||||
}
|
||||
|
||||
export interface LbMedium {
|
||||
format: string;
|
||||
name: string;
|
||||
position: number;
|
||||
tracks: LbTrack[];
|
||||
}
|
||||
|
||||
export interface LbListener {
|
||||
listen_count: number;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
export interface LbListeningStats {
|
||||
artist_mbids: string[];
|
||||
artist_name: string;
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
from_ts: number;
|
||||
last_updated: number;
|
||||
listeners: LbListener[];
|
||||
release_group_mbid: string;
|
||||
release_group_name: string;
|
||||
stats_range: string;
|
||||
to_ts: number;
|
||||
total_listen_count: number;
|
||||
total_user_count: number;
|
||||
}
|
||||
|
||||
export interface LbAlbumDetails {
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
listening_stats: LbListeningStats;
|
||||
mediums: LbMedium[];
|
||||
recordings_release_mbid: string;
|
||||
release_group_mbid: string;
|
||||
release_group_metadata: {
|
||||
artist: {
|
||||
artist_credit_id: number;
|
||||
artists: {
|
||||
area: string;
|
||||
artist_mbid: string;
|
||||
begin_year: number;
|
||||
join_phrase: string;
|
||||
name: string;
|
||||
rels: { [key: string]: string };
|
||||
type: string;
|
||||
}[];
|
||||
name: string;
|
||||
};
|
||||
release: {
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
date: string;
|
||||
name: string;
|
||||
rels: any[];
|
||||
type: string;
|
||||
};
|
||||
release_group: {
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
date: string;
|
||||
name: string;
|
||||
rels: any[];
|
||||
type: string;
|
||||
};
|
||||
tag: {
|
||||
artist: {
|
||||
artist_mbid: string;
|
||||
count: number;
|
||||
tag: string;
|
||||
}[];
|
||||
release_group: {
|
||||
count: number;
|
||||
genre_mbid: string;
|
||||
tag: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LbArtistRels {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface LbArtistTag {
|
||||
artist_mbid: string;
|
||||
count: number;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface LbArtistMetadata {
|
||||
area: string;
|
||||
artist_mbid: string;
|
||||
begin_year: number;
|
||||
mbid: string;
|
||||
name: string;
|
||||
rels: LbArtistRels;
|
||||
tag: {
|
||||
artist: LbArtistTag[];
|
||||
};
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LbPopularRecording {
|
||||
artist_mbids: string[];
|
||||
artist_name: string;
|
||||
artists: LbArtist[];
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
length: number;
|
||||
recording_mbid: string;
|
||||
recording_name: string;
|
||||
release_color?: {
|
||||
blue: number;
|
||||
green: number;
|
||||
red: number;
|
||||
};
|
||||
release_mbid: string;
|
||||
release_name: string;
|
||||
total_listen_count: number;
|
||||
total_user_count: number;
|
||||
}
|
||||
|
||||
export interface LbReleaseGroupExtended extends LbReleaseGroup {
|
||||
artist_credit_name: string;
|
||||
artists: LbArtist[];
|
||||
date: string;
|
||||
mbid: string;
|
||||
type: string;
|
||||
name: string;
|
||||
secondary_types?: string[];
|
||||
total_listen_count: number;
|
||||
}
|
||||
|
||||
export interface LbArtistDetails {
|
||||
artist: LbArtistMetadata;
|
||||
coverArt: string;
|
||||
listeningStats: LbListeningStats;
|
||||
popularRecordings: LbPopularRecording[];
|
||||
releaseGroups: LbReleaseGroupExtended[];
|
||||
similarArtists: {
|
||||
artists: LbSimilarArtistResponse[];
|
||||
topRecordingColor: {
|
||||
blue: number;
|
||||
green: number;
|
||||
red: number;
|
||||
};
|
||||
topReleaseGroupColor: {
|
||||
blue: number;
|
||||
green: number;
|
||||
red: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface LbArtist {
|
||||
artist_mbid: string;
|
||||
artist_name: string;
|
||||
listen_count: number;
|
||||
}
|
||||
|
||||
export interface LbTopArtistsResponse {
|
||||
payload: {
|
||||
count: number;
|
||||
from_ts: number;
|
||||
last_updated: number;
|
||||
offset: number;
|
||||
range: string;
|
||||
artists: LbArtist[];
|
||||
to_ts: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LbRelease {
|
||||
artist_credit_name: string;
|
||||
artist_mbids: string[];
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
listen_count: number;
|
||||
release_date: string;
|
||||
release_group_mbid: string;
|
||||
release_group_primary_type: string;
|
||||
release_group_secondary_type: string;
|
||||
release_mbid: string;
|
||||
release_name: string;
|
||||
release_tags: string[];
|
||||
}
|
||||
|
||||
export interface LbFreshReleasesResponse {
|
||||
payload: {
|
||||
releases: LbRelease[];
|
||||
};
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import axios from 'axios';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { MbAlbumDetails, MbArtistDetails } from './interfaces';
|
||||
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
|
||||
class MusicBrainz extends ExternalAPI {
|
||||
constructor() {
|
||||
super(
|
||||
'https://musicbrainz.org/ws/2',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('musicbrainz').data,
|
||||
rateLimit: {
|
||||
maxRequests: 1,
|
||||
maxRPS: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async searchAlbum({
|
||||
query,
|
||||
limit = 30,
|
||||
offset = 0,
|
||||
}: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MbAlbumDetails[]> {
|
||||
try {
|
||||
const data = await this.get<{
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
'release-groups': MbAlbumDetails[];
|
||||
}>(
|
||||
'/release-group',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
fmt: 'json',
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data['release-groups'];
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to search albums: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchArtist({
|
||||
query,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
}: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MbArtistDetails[]> {
|
||||
try {
|
||||
const data = await this.get<{
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
artists: MbArtistDetails[];
|
||||
}>(
|
||||
'/artist',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
fmt: 'json',
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data.artists;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to search artists: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getArtistWikipediaExtract({
|
||||
artistMbid,
|
||||
language = 'en',
|
||||
}: {
|
||||
artistMbid: string;
|
||||
language?: string;
|
||||
}): Promise<{ title: string; url: string; content: string } | null> {
|
||||
if (
|
||||
!artistMbid ||
|
||||
typeof artistMbid !== 'string' ||
|
||||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
artistMbid
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid MusicBrainz artist ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
const safeUrl = `https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`;
|
||||
|
||||
const response = await axios.get(safeUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': language,
|
||||
'User-Agent':
|
||||
'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)',
|
||||
},
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (!data.wikipediaExtract || !data.wikipediaExtract.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanContent = purify.sanitize(data.wikipediaExtract.content, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
|
||||
return {
|
||||
title: data.wikipediaExtract.title,
|
||||
url: data.wikipediaExtract.url,
|
||||
content: cleanContent.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to fetch Wikipedia extract: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getReleaseGroup({
|
||||
releaseId,
|
||||
}: {
|
||||
releaseId: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const data = await this.get<{
|
||||
'release-group': {
|
||||
id: string;
|
||||
};
|
||||
}>(
|
||||
`/release/${releaseId}`,
|
||||
{
|
||||
params: {
|
||||
inc: 'release-groups',
|
||||
fmt: 'json',
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data['release-group']?.id ?? null;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to fetch release group: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MusicBrainz;
|
||||
@@ -1,119 +0,0 @@
|
||||
interface MbResult {
|
||||
id: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface MbLink {
|
||||
type: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface MbAlbumResult extends MbResult {
|
||||
media_type: 'album';
|
||||
title: string;
|
||||
'primary-type': 'Album' | 'Single' | 'EP';
|
||||
'first-release-date': string;
|
||||
'artist-credit': {
|
||||
name: string;
|
||||
artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
'sort-name': string;
|
||||
overview?: string;
|
||||
};
|
||||
}[];
|
||||
posterPath: string | undefined;
|
||||
}
|
||||
|
||||
export interface MbAlbumDetails extends MbAlbumResult {
|
||||
'type-id': string;
|
||||
'primary-type-id': string;
|
||||
count: number;
|
||||
'secondary-types'?: string[];
|
||||
'secondary-type-ids'?: string[];
|
||||
releases: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
'status-id': string;
|
||||
}[];
|
||||
releasedate: string;
|
||||
tags?: {
|
||||
count: number;
|
||||
name: string;
|
||||
}[];
|
||||
artists?: {
|
||||
id: string;
|
||||
name: string;
|
||||
overview?: string;
|
||||
}[];
|
||||
links?: MbLink[];
|
||||
poster_path?: string;
|
||||
}
|
||||
|
||||
export interface MbArtistResult extends MbResult {
|
||||
media_type: 'artist';
|
||||
name: string;
|
||||
type: 'Group' | 'Person';
|
||||
'sort-name': string;
|
||||
country?: string;
|
||||
disambiguation?: string;
|
||||
artistThumb?: string | null;
|
||||
artistBackdrop?: string | null;
|
||||
}
|
||||
|
||||
export interface MbArtistDetails extends MbArtistResult {
|
||||
'type-id': string;
|
||||
area?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
'type-id': string;
|
||||
'sort-name': string;
|
||||
};
|
||||
'begin-area'?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
'sort-name': string;
|
||||
};
|
||||
'life-span'?: {
|
||||
begin?: string;
|
||||
ended: boolean;
|
||||
};
|
||||
gender?: string;
|
||||
'gender-id'?: string;
|
||||
isnis?: string[];
|
||||
aliases?: {
|
||||
name: string;
|
||||
'sort-name': string;
|
||||
type?: string;
|
||||
'type-id'?: string;
|
||||
}[];
|
||||
tags?: {
|
||||
count: number;
|
||||
name: string;
|
||||
}[];
|
||||
links?: MbLink[];
|
||||
}
|
||||
|
||||
export interface MbSearchAlbumResponse {
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
'release-groups': MbAlbumDetails[];
|
||||
}
|
||||
|
||||
export interface MbSearchArtistResponse {
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
artists: MbArtistDetails[];
|
||||
}
|
||||
|
||||
export interface MbSearchMultiResponse {
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
results: (MbArtistResult | MbAlbumResult)[];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -16,7 +23,7 @@ export interface PlexLibraryItem {
|
||||
Guid?: {
|
||||
id: string;
|
||||
}[];
|
||||
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
Media: Media[];
|
||||
}
|
||||
|
||||
@@ -28,7 +35,7 @@ interface PlexLibraryResponse {
|
||||
}
|
||||
|
||||
export interface PlexLibrary {
|
||||
type: 'show' | 'movie' | 'artist';
|
||||
type: 'show' | 'movie';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
@@ -44,7 +51,7 @@ export interface PlexMetadata {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
guid: string;
|
||||
type: 'movie' | 'show' | 'season' | 'artist' | 'album' | 'track';
|
||||
type: 'movie' | 'show' | 'season';
|
||||
title: string;
|
||||
Guid: {
|
||||
id: 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;
|
||||
}
|
||||
@@ -152,10 +142,7 @@ class PlexAPI {
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter(
|
||||
(library) =>
|
||||
library.type === 'movie' ||
|
||||
library.type === 'show' ||
|
||||
library.type === 'artist'
|
||||
(library) => library.type === 'movie' || library.type === 'show'
|
||||
)
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
@@ -168,7 +155,7 @@ class PlexAPI {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
type: library.type === 'artist' ? 'music' : library.type,
|
||||
type: library.type,
|
||||
lastScan: existing?.lastScan,
|
||||
};
|
||||
});
|
||||
@@ -190,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,
|
||||
@@ -208,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' : ''
|
||||
}`
|
||||
@@ -218,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`
|
||||
);
|
||||
|
||||
@@ -230,24 +219,19 @@ class PlexAPI {
|
||||
options: { addedAt: number } = {
|
||||
addedAt: Date.now() - 1000 * 60 * 60,
|
||||
},
|
||||
mediaType: 'movie' | 'show' | 'album'
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
let typeCode = '1';
|
||||
if (mediaType === 'show') {
|
||||
typeCode = '4';
|
||||
} else if (mediaType === 'album') {
|
||||
typeCode = '9';
|
||||
}
|
||||
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?type=${typeCode}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
||||
options.addedAt / 1000
|
||||
)}`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
},
|
||||
});
|
||||
const response = await this.get<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Container-Start': '0',
|
||||
'X-Plex-Container-Size': '500',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export interface PlexWatchlistItem {
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
tvdbId?: number;
|
||||
type: 'movie' | 'show' | 'album';
|
||||
type: 'movie' | 'show';
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
interface LidarrMediaResult {
|
||||
id: number;
|
||||
mbId: string;
|
||||
media_type: string;
|
||||
}
|
||||
|
||||
export interface LidarrArtistResult extends LidarrMediaResult {
|
||||
artist: {
|
||||
media_type: 'artist';
|
||||
artistName: string;
|
||||
overview: string;
|
||||
remotePoster?: string;
|
||||
artistType: string;
|
||||
genres: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarrAlbumResult extends LidarrMediaResult {
|
||||
album: {
|
||||
disambiguation: string;
|
||||
duration: number;
|
||||
mediumCount: number;
|
||||
ratings: LidarrRating | undefined;
|
||||
links: never[];
|
||||
media_type: 'music';
|
||||
title: string;
|
||||
foreignAlbumId: string;
|
||||
overview: string;
|
||||
releaseDate: string;
|
||||
albumType: string;
|
||||
genres: string[];
|
||||
images: LidarrImage[];
|
||||
artist: {
|
||||
id: number;
|
||||
status: string;
|
||||
ended: boolean;
|
||||
foreignArtistId: string;
|
||||
tadbId: number;
|
||||
discogsId: number;
|
||||
artistType: string;
|
||||
disambiguation: string | undefined;
|
||||
links: never[];
|
||||
images: never[];
|
||||
genres: never[];
|
||||
cleanName: string | undefined;
|
||||
sortName: string | undefined;
|
||||
tags: never[];
|
||||
added: string;
|
||||
ratings: LidarrRating | undefined;
|
||||
artistName: string;
|
||||
overview: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarrArtistDetails {
|
||||
id: number;
|
||||
foreignArtistId: string;
|
||||
status: string;
|
||||
ended: boolean;
|
||||
artistName: string;
|
||||
tadbId: number;
|
||||
discogsId: number;
|
||||
overview: string;
|
||||
artistType: string;
|
||||
disambiguation: string;
|
||||
links: LidarrLink[];
|
||||
nextAlbum: LidarrAlbumResult | null;
|
||||
lastAlbum: LidarrAlbumResult | null;
|
||||
images: LidarrImage[];
|
||||
qualityProfileId: number;
|
||||
profileId: number;
|
||||
metadataProfileId: number;
|
||||
monitored: boolean;
|
||||
monitorNewItems: string;
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
added: string;
|
||||
ratings: LidarrRating;
|
||||
remotePoster?: string;
|
||||
cleanName?: string;
|
||||
sortName?: string;
|
||||
}
|
||||
|
||||
export interface LidarrAlbumDetails {
|
||||
id: number;
|
||||
mbId: string;
|
||||
foreignArtistId: string;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
title: string;
|
||||
titleSlug: string;
|
||||
path: string;
|
||||
artistName: string;
|
||||
disambiguation: string;
|
||||
overview: string;
|
||||
artistId: number;
|
||||
foreignAlbumId: string;
|
||||
anyReleaseOk: boolean;
|
||||
profileId: number;
|
||||
qualityProfileId: number;
|
||||
duration: number;
|
||||
isAvailable: boolean;
|
||||
folderName: string;
|
||||
metadataProfileId: number;
|
||||
added: string;
|
||||
albumType: string;
|
||||
secondaryTypes: string[];
|
||||
mediumCount: number;
|
||||
ratings: LidarrRating;
|
||||
releaseDate: string;
|
||||
releases: {
|
||||
id: number;
|
||||
albumId: number;
|
||||
foreignReleaseId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
media: unknown[];
|
||||
mediumCount: number;
|
||||
disambiguation: string;
|
||||
country: unknown[];
|
||||
label: unknown[];
|
||||
format: string;
|
||||
monitored: boolean;
|
||||
}[];
|
||||
genres: string[];
|
||||
media: {
|
||||
mediumNumber: number;
|
||||
mediumName: string;
|
||||
mediumFormat: string;
|
||||
}[];
|
||||
artist: LidarrArtistDetails & {
|
||||
artistName: string;
|
||||
nextAlbum: unknown | null;
|
||||
lastAlbum: unknown | null;
|
||||
};
|
||||
images: LidarrImage[];
|
||||
links: {
|
||||
url: string;
|
||||
name: string;
|
||||
}[];
|
||||
remoteCover?: string;
|
||||
}
|
||||
|
||||
export interface LidarrImage {
|
||||
url: string;
|
||||
coverType: string;
|
||||
}
|
||||
|
||||
export interface LidarrRating {
|
||||
votes: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LidarrLink {
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LidarrRelease {
|
||||
id: number;
|
||||
albumId: number;
|
||||
foreignReleaseId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
media: LidarrMedia[];
|
||||
}
|
||||
|
||||
export interface LidarrMedia {
|
||||
mediumNumber: number;
|
||||
mediumFormat: string;
|
||||
mediumName: string;
|
||||
}
|
||||
|
||||
export interface LidarrSearchResponse {
|
||||
page: number;
|
||||
total_results: number;
|
||||
total_pages: number;
|
||||
results: (LidarrArtistResult | LidarrAlbumResult)[];
|
||||
}
|
||||
|
||||
export interface LidarrAlbumOptions {
|
||||
[key: string]: unknown;
|
||||
title: string;
|
||||
disambiguation?: string;
|
||||
overview?: string;
|
||||
artistId: number;
|
||||
foreignAlbumId: string;
|
||||
monitored: boolean;
|
||||
anyReleaseOk: boolean;
|
||||
profileId: number;
|
||||
duration?: number;
|
||||
albumType: string;
|
||||
secondaryTypes: string[];
|
||||
mediumCount?: number;
|
||||
ratings?: LidarrRating;
|
||||
releaseDate?: string;
|
||||
releases: unknown[];
|
||||
genres: string[];
|
||||
media: unknown[];
|
||||
artist: {
|
||||
status: string;
|
||||
ended: boolean;
|
||||
artistName: string;
|
||||
foreignArtistId: string;
|
||||
tadbId?: number;
|
||||
discogsId?: number;
|
||||
overview?: string;
|
||||
artistType: string;
|
||||
disambiguation?: string;
|
||||
links: LidarrLink[];
|
||||
images: LidarrImage[];
|
||||
path: string;
|
||||
qualityProfileId: number;
|
||||
metadataProfileId: number;
|
||||
monitored: boolean;
|
||||
monitorNewItems: string;
|
||||
rootFolderPath: string;
|
||||
genres: string[];
|
||||
cleanName?: string;
|
||||
sortName?: string;
|
||||
tags: number[];
|
||||
added?: string;
|
||||
ratings?: LidarrRating;
|
||||
id: number;
|
||||
};
|
||||
images: LidarrImage[];
|
||||
links: LidarrLink[];
|
||||
addOptions: {
|
||||
searchForNewAlbum: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarrArtistOptions {
|
||||
[key: string]: unknown;
|
||||
artistName: string;
|
||||
qualityProfileId: number;
|
||||
profileId: number;
|
||||
rootFolderPath: string;
|
||||
foreignArtistId: string;
|
||||
monitored: boolean;
|
||||
tags: number[];
|
||||
searchNow: boolean;
|
||||
monitorNewItems: string;
|
||||
monitor: string;
|
||||
searchForMissingAlbums: boolean;
|
||||
}
|
||||
|
||||
export interface LidarrAlbum {
|
||||
id: number;
|
||||
mbId: string;
|
||||
title: string;
|
||||
monitored: boolean;
|
||||
artistId: number;
|
||||
foreignAlbumId: string;
|
||||
titleSlug: string;
|
||||
profileId: number;
|
||||
duration: number;
|
||||
albumType: string;
|
||||
statistics: {
|
||||
trackFileCount: number;
|
||||
trackCount: number;
|
||||
totalTrackCount: number;
|
||||
sizeOnDisk: number;
|
||||
percentOfTracks: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchCommand extends Record<string, unknown> {
|
||||
name: 'AlbumSearch';
|
||||
albumIds: number[];
|
||||
}
|
||||
|
||||
export interface MetadataProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
class LidarrAPI extends ServarrBase<{ albumId: number }> {
|
||||
protected apiKey: string;
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public async getAlbums(): Promise<LidarrAlbum[]> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbum[]>('/album');
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAlbum({ id }: { id: number }): Promise<LidarrAlbum> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbum>(`/album/${id}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async removeAlbum(albumId: number): Promise<void> {
|
||||
try {
|
||||
await this.axios.delete(`/album/${albumId}`, {
|
||||
params: {
|
||||
deleteFiles: 'true',
|
||||
addImportExclusion: 'false',
|
||||
},
|
||||
});
|
||||
logger.info(`[Lidarr] Removed album ${albumId}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to remove album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchAlbum(mbid: string): Promise<LidarrAlbumResult[]> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbumResult[]>('/search', {
|
||||
params: {
|
||||
term: `lidarr:${mbid}`,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to search album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async addAlbum(options: LidarrAlbumOptions): Promise<LidarrAlbum> {
|
||||
try {
|
||||
const existingAlbums = await this.get<LidarrAlbum[]>('/album', {
|
||||
params: {
|
||||
foreignAlbumId: options.foreignAlbumId,
|
||||
includeAllArtistAlbums: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAlbums.length > 0 && existingAlbums[0].monitored) {
|
||||
logger.info(
|
||||
'Album is already monitored in Lidarr. Skipping add and returning success',
|
||||
{
|
||||
label: 'Lidarr',
|
||||
}
|
||||
);
|
||||
return existingAlbums[0];
|
||||
}
|
||||
|
||||
if (existingAlbums.length > 0) {
|
||||
logger.info(
|
||||
'Album exists in Lidarr but is not monitored. Updating monitored status.',
|
||||
{
|
||||
label: 'Lidarr',
|
||||
albumId: existingAlbums[0].id,
|
||||
albumTitle: existingAlbums[0].title,
|
||||
}
|
||||
);
|
||||
|
||||
const updatedAlbum = await this.axios.put<LidarrAlbum>(
|
||||
`/album/${existingAlbums[0].id}`,
|
||||
{
|
||||
...existingAlbums[0],
|
||||
monitored: true,
|
||||
}
|
||||
);
|
||||
|
||||
await this.post('/command', {
|
||||
name: 'AlbumSearch',
|
||||
albumIds: [updatedAlbum.data.id],
|
||||
});
|
||||
|
||||
return updatedAlbum.data;
|
||||
}
|
||||
|
||||
const data = await this.post<LidarrAlbum>('/album', {
|
||||
...options,
|
||||
monitored: true,
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to add album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchAlbumByMusicBrainzId(
|
||||
mbid: string
|
||||
): Promise<LidarrAlbumResult[]> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbumResult[]>('/search', {
|
||||
params: {
|
||||
term: `lidarr:${mbid}`,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[Lidarr] Failed to search album by MusicBrainz ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMetadataProfiles(): Promise<MetadataProfile[]> {
|
||||
try {
|
||||
const data = await this.get<MetadataProfile[]>('/metadataProfile');
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[Lidarr] Failed to retrieve metadata profiles: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LidarrAPI;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import { In } from 'typeorm';
|
||||
import type { TadbArtistResponse } from './interfaces';
|
||||
|
||||
class TheAudioDb extends ExternalAPI {
|
||||
private readonly apiKey = '195003';
|
||||
private readonly CACHE_TTL = 43200;
|
||||
private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'https://www.theaudiodb.com/api/v1/json',
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tadb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 25,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private isMetadataStale(metadata: MetadataArtist | null): boolean {
|
||||
if (!metadata || !metadata.tadbUpdatedAt) return true;
|
||||
return Date.now() - metadata.tadbUpdatedAt.getTime() > this.STALE_THRESHOLD;
|
||||
}
|
||||
|
||||
private createEmptyResponse() {
|
||||
return { artistThumb: null, artistBackground: null };
|
||||
}
|
||||
|
||||
public async getArtistImagesFromCache(id: string): Promise<
|
||||
| {
|
||||
artistThumb: string | null;
|
||||
artistBackground: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
> {
|
||||
try {
|
||||
const metadata = await getRepository(MetadataArtist).findOne({
|
||||
where: { mbArtistId: id },
|
||||
select: ['tadbThumb', 'tadbCover', 'tadbUpdatedAt'],
|
||||
});
|
||||
|
||||
if (metadata) {
|
||||
return {
|
||||
artistThumb: metadata.tadbThumb,
|
||||
artistBackground: metadata.tadbCover,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch artist images from cache', {
|
||||
label: 'TheAudioDb',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async getArtistImages(
|
||||
id: string
|
||||
): Promise<{ artistThumb: string | null; artistBackground: string | null }> {
|
||||
try {
|
||||
const metadata = await getRepository(MetadataArtist).findOne({
|
||||
where: { mbArtistId: id },
|
||||
select: ['tadbThumb', 'tadbCover', 'tadbUpdatedAt'],
|
||||
});
|
||||
|
||||
if (metadata?.tadbThumb || metadata?.tadbCover) {
|
||||
return {
|
||||
artistThumb: metadata.tadbThumb,
|
||||
artistBackground: metadata.tadbCover,
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata && !this.isMetadataStale(metadata)) {
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
|
||||
return await this.fetchArtistImages(id);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get artist images', {
|
||||
label: 'TheAudioDb',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchArtistImages(id: string): Promise<{
|
||||
artistThumb: string | null;
|
||||
artistBackground: string | null;
|
||||
}> {
|
||||
try {
|
||||
const data = await this.get<TadbArtistResponse>(
|
||||
`/${this.apiKey}/artist-mb.php`,
|
||||
{ params: { i: id } },
|
||||
this.CACHE_TTL
|
||||
);
|
||||
|
||||
const result = {
|
||||
artistThumb: data.artists?.[0]?.strArtistThumb || null,
|
||||
artistBackground: data.artists?.[0]?.strArtistFanart || null,
|
||||
};
|
||||
|
||||
const metadataRepository = getRepository(MetadataArtist);
|
||||
await metadataRepository
|
||||
.upsert(
|
||||
{
|
||||
mbArtistId: id,
|
||||
tadbThumb: result.artistThumb,
|
||||
tadbCover: result.artistBackground,
|
||||
tadbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error('Failed to save artist metadata', {
|
||||
label: 'TheAudioDb',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await getRepository(MetadataArtist).upsert(
|
||||
{
|
||||
mbArtistId: id,
|
||||
tadbThumb: null,
|
||||
tadbCover: null,
|
||||
tadbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
);
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async batchGetArtistImages(ids: string[]): Promise<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
artistThumb: string | null;
|
||||
artistBackground: string | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
if (!ids.length) return {};
|
||||
|
||||
const metadataRepository = getRepository(MetadataArtist);
|
||||
const existingMetadata = await metadataRepository.find({
|
||||
where: { mbArtistId: In(ids) },
|
||||
select: ['mbArtistId', 'tadbThumb', 'tadbCover', 'tadbUpdatedAt'],
|
||||
});
|
||||
|
||||
const results: Record<
|
||||
string,
|
||||
{
|
||||
artistThumb: string | null;
|
||||
artistBackground: string | null;
|
||||
}
|
||||
> = {};
|
||||
const idsToFetch: string[] = [];
|
||||
|
||||
ids.forEach((id) => {
|
||||
const metadata = existingMetadata.find((m) => m.mbArtistId === id);
|
||||
|
||||
if (metadata?.tadbThumb || metadata?.tadbCover) {
|
||||
results[id] = {
|
||||
artistThumb: metadata.tadbThumb,
|
||||
artistBackground: metadata.tadbCover,
|
||||
};
|
||||
} else if (metadata && !this.isMetadataStale(metadata)) {
|
||||
results[id] = {
|
||||
artistThumb: null,
|
||||
artistBackground: null,
|
||||
};
|
||||
} else {
|
||||
idsToFetch.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (idsToFetch.length > 0) {
|
||||
const batchPromises = idsToFetch.map((id) =>
|
||||
this.fetchArtistImages(id)
|
||||
.then((response) => {
|
||||
results[id] = response;
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
results[id] = {
|
||||
artistThumb: null,
|
||||
artistBackground: null,
|
||||
};
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default TheAudioDb;
|
||||
@@ -1,8 +0,0 @@
|
||||
interface TadbArtist {
|
||||
strArtistThumb: string | null;
|
||||
strArtistFanart: string | null;
|
||||
}
|
||||
|
||||
export interface TadbArtistResponse {
|
||||
artists?: TadbArtist[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface TmdbCollectionResult {
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
id: number;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
popularity: number;
|
||||
profile_path?: string;
|
||||
@@ -393,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;
|
||||
}
|
||||
@@ -465,10 +466,6 @@ export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbCompany[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchPersonResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbPersonResult[];
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderRegion {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import { In } from 'typeorm';
|
||||
import type { TmdbSearchPersonResponse } from './interfaces';
|
||||
|
||||
interface SearchPersonOptions {
|
||||
query: string;
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
class TmdbPersonMapper extends ExternalAPI {
|
||||
private readonly CACHE_TTL = 43200;
|
||||
private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000;
|
||||
private tmdb: TheMovieDb;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'https://api.themoviedb.org/3',
|
||||
{
|
||||
api_key: '431a8708161bcd1f1fbe7536137e61ed',
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.tmdb = new TheMovieDb();
|
||||
}
|
||||
|
||||
private isMetadataStale(metadata: MetadataArtist | null): boolean {
|
||||
if (!metadata || !metadata.tmdbUpdatedAt) return true;
|
||||
return Date.now() - metadata.tmdbUpdatedAt.getTime() > this.STALE_THRESHOLD;
|
||||
}
|
||||
|
||||
private createEmptyResponse() {
|
||||
return { personId: null, profilePath: null };
|
||||
}
|
||||
|
||||
public async getMappingFromCache(
|
||||
artistId: string
|
||||
): Promise<{ personId: number | null; profilePath: string | null } | null> {
|
||||
try {
|
||||
const metadata = await getRepository(MetadataArtist).findOne({
|
||||
where: { mbArtistId: artistId },
|
||||
select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'],
|
||||
});
|
||||
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isMetadataStale(metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
personId: metadata.tmdbPersonId ? Number(metadata.tmdbPersonId) : null,
|
||||
profilePath: metadata.tmdbThumb,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get person mapping from cache', {
|
||||
label: 'TmdbPersonMapper',
|
||||
artistId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async getMapping(
|
||||
artistId: string,
|
||||
artistName: string
|
||||
): Promise<{ personId: number | null; profilePath: string | null }> {
|
||||
try {
|
||||
const metadata = await getRepository(MetadataArtist).findOne({
|
||||
where: { mbArtistId: artistId },
|
||||
select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'],
|
||||
});
|
||||
|
||||
if (metadata?.tmdbPersonId || metadata?.tmdbThumb) {
|
||||
return {
|
||||
personId: metadata.tmdbPersonId
|
||||
? Number(metadata.tmdbPersonId)
|
||||
: null,
|
||||
profilePath: metadata.tmdbThumb,
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata && !this.isMetadataStale(metadata)) {
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
|
||||
return await this.fetchMapping(artistId, artistName);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get person mapping', {
|
||||
label: 'TmdbPersonMapper',
|
||||
artistId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMapping(
|
||||
artistId: string,
|
||||
artistName: string
|
||||
): Promise<{ personId: number | null; profilePath: string | null }> {
|
||||
try {
|
||||
const existingMetadata = await getRepository(MetadataArtist).findOne({
|
||||
where: { mbArtistId: artistId },
|
||||
select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'],
|
||||
});
|
||||
|
||||
if (existingMetadata?.tmdbPersonId) {
|
||||
return {
|
||||
personId: Number(existingMetadata.tmdbPersonId),
|
||||
profilePath: existingMetadata.tmdbThumb,
|
||||
};
|
||||
}
|
||||
|
||||
const cleanArtistName = artistName
|
||||
.split(/(?:(?:feat|ft)\.?\s+|&\s*|,\s+)/i)[0]
|
||||
.trim()
|
||||
.replace(/['′]/g, "'");
|
||||
|
||||
const searchResults = await this.get<TmdbSearchPersonResponse>(
|
||||
'/search/person',
|
||||
{
|
||||
params: {
|
||||
query: cleanArtistName,
|
||||
page: '1',
|
||||
include_adult: 'false',
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
this.CACHE_TTL
|
||||
);
|
||||
|
||||
const normalizeName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/['′]/g, "'")
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const exactMatches = searchResults.results.filter((person) => {
|
||||
const normalizedPersonName = normalizeName(person.name);
|
||||
const normalizedArtistName = normalizeName(cleanArtistName);
|
||||
|
||||
return normalizedPersonName === normalizedArtistName;
|
||||
});
|
||||
|
||||
if (exactMatches.length > 0) {
|
||||
const tmdbPersonIds = exactMatches.map((match) => match.id.toString());
|
||||
const existingMappings = await getRepository(MetadataArtist).find({
|
||||
where: { tmdbPersonId: In(tmdbPersonIds) },
|
||||
select: ['mbArtistId', 'tmdbPersonId'],
|
||||
});
|
||||
|
||||
const availableMatches = exactMatches.filter(
|
||||
(match) =>
|
||||
!existingMappings.some(
|
||||
(mapping) =>
|
||||
mapping.tmdbPersonId === match.id.toString() &&
|
||||
mapping.mbArtistId !== artistId
|
||||
)
|
||||
);
|
||||
|
||||
const soundMatches = availableMatches.filter(
|
||||
(person) => person.known_for_department === 'Sound'
|
||||
);
|
||||
|
||||
const exactMatch =
|
||||
soundMatches.length > 0
|
||||
? soundMatches.reduce((prev, current) =>
|
||||
current.popularity > prev.popularity ? current : prev
|
||||
)
|
||||
: availableMatches.length > 0
|
||||
? availableMatches.reduce((prev, current) =>
|
||||
current.popularity > prev.popularity ? current : prev
|
||||
)
|
||||
: null;
|
||||
|
||||
const mapping = {
|
||||
personId: exactMatch?.id ?? null,
|
||||
profilePath: exactMatch?.profile_path
|
||||
? `https://image.tmdb.org/t/p/w500${exactMatch.profile_path}`
|
||||
: null,
|
||||
};
|
||||
|
||||
await getRepository(MetadataArtist)
|
||||
.upsert(
|
||||
{
|
||||
mbArtistId: artistId,
|
||||
tmdbPersonId: mapping.personId?.toString() ?? null,
|
||||
tmdbThumb: mapping.profilePath,
|
||||
tmdbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error('Failed to save artist metadata', {
|
||||
label: 'TmdbPersonMapper',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
|
||||
return mapping;
|
||||
} else {
|
||||
await getRepository(MetadataArtist).upsert(
|
||||
{
|
||||
mbArtistId: artistId,
|
||||
tmdbPersonId: null,
|
||||
tmdbThumb: null,
|
||||
tmdbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
);
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
} catch (error) {
|
||||
await getRepository(MetadataArtist).upsert(
|
||||
{
|
||||
mbArtistId: artistId,
|
||||
tmdbPersonId: null,
|
||||
tmdbThumb: null,
|
||||
tmdbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
);
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async batchGetMappings(
|
||||
artists: { artistId: string; artistName: string }[]
|
||||
): Promise<
|
||||
Record<string, { personId: number | null; profilePath: string | null }>
|
||||
> {
|
||||
if (!artists.length) return {};
|
||||
|
||||
const metadataRepository = getRepository(MetadataArtist);
|
||||
const artistIds = artists.map((a) => a.artistId);
|
||||
|
||||
const existingMetadata = await metadataRepository.find({
|
||||
where: { mbArtistId: In(artistIds) },
|
||||
select: ['mbArtistId', 'tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'],
|
||||
});
|
||||
|
||||
const results: Record<
|
||||
string,
|
||||
{ personId: number | null; profilePath: string | null }
|
||||
> = {};
|
||||
const artistsToFetch: { artistId: string; artistName: string }[] = [];
|
||||
|
||||
artists.forEach(({ artistId, artistName }) => {
|
||||
const metadata = existingMetadata.find((m) => m.mbArtistId === artistId);
|
||||
|
||||
if (metadata?.tmdbPersonId || metadata?.tmdbThumb) {
|
||||
results[artistId] = {
|
||||
personId: metadata.tmdbPersonId
|
||||
? Number(metadata.tmdbPersonId)
|
||||
: null,
|
||||
profilePath: metadata.tmdbThumb,
|
||||
};
|
||||
} else if (metadata && !this.isMetadataStale(metadata)) {
|
||||
results[artistId] = this.createEmptyResponse();
|
||||
} else {
|
||||
artistsToFetch.push({ artistId, artistName });
|
||||
}
|
||||
});
|
||||
|
||||
if (artistsToFetch.length > 0) {
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < artistsToFetch.length; i += batchSize) {
|
||||
const batch = artistsToFetch.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(({ artistId, artistName }) =>
|
||||
this.fetchMapping(artistId, artistName)
|
||||
.then((mapping) => {
|
||||
results[artistId] = mapping;
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
results[artistId] = this.createEmptyResponse();
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async searchPerson(
|
||||
options: SearchPersonOptions
|
||||
): Promise<TmdbSearchPersonResponse> {
|
||||
try {
|
||||
return await this.get<TmdbSearchPersonResponse>(
|
||||
'/search/person',
|
||||
{
|
||||
params: {
|
||||
query: options.query,
|
||||
page: options.page?.toString() ?? '1',
|
||||
include_adult: options.includeAdult ? 'true' : 'false',
|
||||
language: options.language ?? 'en',
|
||||
},
|
||||
},
|
||||
this.CACHE_TTL
|
||||
);
|
||||
} catch (e) {
|
||||
return {
|
||||
page: 1,
|
||||
results: [],
|
||||
total_pages: 1,
|
||||
total_results: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TmdbPersonMapper;
|
||||
@@ -22,8 +22,6 @@ export enum DiscoverSliderType {
|
||||
TMDB_NETWORK,
|
||||
TMDB_MOVIE_STREAMING_SERVICES,
|
||||
TMDB_TV_STREAMING_SERVICES,
|
||||
POPULAR_ALBUMS,
|
||||
POPULAR_ARTISTS,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
@@ -99,16 +97,4 @@ export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
isBuiltIn: true,
|
||||
order: 11,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_ALBUMS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 12,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_ARTISTS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 13,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,7 +3,6 @@ export enum IssueType {
|
||||
AUDIO = 2,
|
||||
SUBTITLES = 3,
|
||||
OTHER = 4,
|
||||
LYRICS = 5,
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
@@ -16,5 +15,4 @@ export const IssueTypeName = {
|
||||
[IssueType.VIDEO]: 'Video',
|
||||
[IssueType.SUBTITLES]: 'Subtitle',
|
||||
[IssueType.OTHER]: 'Other',
|
||||
[IssueType.LYRICS]: 'Lyrics',
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ export enum MediaRequestStatus {
|
||||
export enum MediaType {
|
||||
MOVIE = 'movie',
|
||||
TV = 'tv',
|
||||
MUSIC = 'music',
|
||||
}
|
||||
|
||||
export enum MediaStatus {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||
|
||||
@Entity()
|
||||
@Unique(['tmdbId', 'mbId'])
|
||||
@Unique(['tmdbId'])
|
||||
export class Blacklist implements BlacklistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
@@ -29,13 +29,9 @@ export class Blacklist implements BlacklistItem {
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
title?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public mbId?: string;
|
||||
public tmdbId: number;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.id, {
|
||||
eager: true,
|
||||
@@ -65,8 +61,7 @@ export class Blacklist implements BlacklistItem {
|
||||
blacklistRequest: {
|
||||
mediaType: MediaType;
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId?: ZodNumber['_output'];
|
||||
mbId?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
blacklistedTags?: string;
|
||||
};
|
||||
},
|
||||
@@ -79,10 +74,9 @@ export class Blacklist implements BlacklistItem {
|
||||
|
||||
const mediaRepository = em.getRepository(Media);
|
||||
let media = await mediaRepository.findOne({
|
||||
where:
|
||||
blacklistRequest.mediaType === 'music'
|
||||
? { mbId: blacklistRequest.mbId }
|
||||
: { tmdbId: blacklistRequest.tmdbId },
|
||||
where: {
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
},
|
||||
});
|
||||
|
||||
const blacklistRepository = em.getRepository(this);
|
||||
@@ -92,7 +86,6 @@ export class Blacklist implements BlacklistItem {
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
mbId: blacklistRequest.mbId,
|
||||
status: MediaStatus.BLACKLISTED,
|
||||
status4k: MediaStatus.BLACKLISTED,
|
||||
mediaType: blacklistRequest.mediaType,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
@@ -30,16 +29,16 @@ import Season from './Season';
|
||||
class Media {
|
||||
public static async getRelatedMedia(
|
||||
user: User | undefined,
|
||||
ids: number | number[] | string | string[]
|
||||
tmdbIds: number | number[]
|
||||
): Promise<Media[]> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
let finalIds: (number | string)[];
|
||||
if (!Array.isArray(ids)) {
|
||||
finalIds = [ids];
|
||||
let finalIds: number[];
|
||||
if (!Array.isArray(tmdbIds)) {
|
||||
finalIds = [tmdbIds];
|
||||
} else {
|
||||
finalIds = ids;
|
||||
finalIds = tmdbIds;
|
||||
}
|
||||
|
||||
if (finalIds.length === 0) {
|
||||
@@ -51,15 +50,10 @@ class Media {
|
||||
.leftJoinAndSelect(
|
||||
'media.watchlists',
|
||||
'watchlist',
|
||||
'media.id = watchlist.media and watchlist.requestedBy = :userId',
|
||||
'media.id= watchlist.media and watchlist.requestedBy = :userId',
|
||||
{ userId: user?.id }
|
||||
)
|
||||
.where(
|
||||
typeof finalIds[0] === 'string'
|
||||
? 'media.mbId IN (:...finalIds)'
|
||||
: 'media.tmdbId IN (:...finalIds)',
|
||||
{ finalIds }
|
||||
)
|
||||
) //,
|
||||
.where(' media.tmdbId in (:...finalIds)', { finalIds })
|
||||
.getMany();
|
||||
|
||||
return media;
|
||||
@@ -70,17 +64,14 @@ class Media {
|
||||
}
|
||||
|
||||
public static async getMedia(
|
||||
id: number | string,
|
||||
id: number,
|
||||
mediaType: MediaType
|
||||
): Promise<Media | undefined> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where:
|
||||
typeof id === 'string'
|
||||
? { mbId: id, mediaType }
|
||||
: { tmdbId: id, mediaType },
|
||||
where: { tmdbId: id, mediaType: mediaType },
|
||||
relations: { requests: true, issues: true },
|
||||
});
|
||||
|
||||
@@ -97,7 +88,7 @@ class Media {
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@@ -109,10 +100,6 @@ class Media {
|
||||
@Index()
|
||||
public imdbId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public mbId?: string;
|
||||
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status: MediaStatus;
|
||||
|
||||
@@ -168,7 +155,7 @@ class Media {
|
||||
})
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
@Column({ nullable: false, type: 'int', default: 0 })
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId?: number | null;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
@@ -332,21 +319,6 @@ class Media {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaType === MediaType.MUSIC) {
|
||||
if (this.serviceId !== null && this.externalServiceSlug !== null) {
|
||||
const settings = getSettings();
|
||||
const server = settings.lidarr.find(
|
||||
(lidarr) => lidarr.id === this.serviceId
|
||||
);
|
||||
|
||||
if (server) {
|
||||
this.serviceUrl = server.externalUrl
|
||||
? `${server.externalUrl}/album/${this.externalServiceSlug}`
|
||||
: LidarrAPI.buildUrl(server, `/album/${this.externalServiceSlug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
@@ -402,20 +374,6 @@ class Media {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaType === MediaType.MUSIC) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getMusicProgress(
|
||||
this.serviceId,
|
||||
this.externalServiceId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import CoverArtArchive from '@server/api/coverartarchive';
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
@@ -29,6 +21,7 @@ import {
|
||||
AfterUpdate,
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -56,7 +49,6 @@ export class MediaRequest {
|
||||
options: MediaRequestOptions = {}
|
||||
): Promise<MediaRequest> {
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenBrainz = new ListenBrainzAPI();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
@@ -124,55 +116,25 @@ export class MediaRequest {
|
||||
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
||||
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||
} else if (
|
||||
requestBody.mediaType === MediaType.MUSIC &&
|
||||
quotas.music.restricted
|
||||
) {
|
||||
throw new QuotaRestrictedError('Music Quota exceeded.');
|
||||
}
|
||||
|
||||
const requestedMedia =
|
||||
const tmdbMedia =
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||
: requestBody.mediaType === MediaType.TV
|
||||
? await tmdb.getTvShow({ tvId: requestBody.mediaId })
|
||||
: await listenBrainz.getAlbum(requestBody.mediaId.toString());
|
||||
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where:
|
||||
requestBody.mediaType === MediaType.MUSIC
|
||||
? {
|
||||
mbId: requestBody.mediaId.toString(),
|
||||
mediaType: requestBody.mediaType,
|
||||
}
|
||||
: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
where: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
const isTmdbMedia = (
|
||||
media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails
|
||||
): media is TmdbMovieDetails | TmdbTvDetails => {
|
||||
return 'id' in media;
|
||||
};
|
||||
|
||||
const isLbAlbum = (
|
||||
media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails
|
||||
): media is LbAlbumDetails => {
|
||||
return 'release_group_mbid' in media;
|
||||
};
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: isTmdbMedia(requestedMedia) ? requestedMedia.id : undefined,
|
||||
mbId: isLbAlbum(requestedMedia)
|
||||
? requestedMedia.release_group_mbid
|
||||
: undefined,
|
||||
tvdbId: isTmdbMedia(requestedMedia)
|
||||
? requestBody.tvdbId ?? requestedMedia.external_ids?.tvdb_id
|
||||
: undefined,
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
||||
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
mediaType: requestBody.mediaType,
|
||||
@@ -180,9 +142,7 @@ export class MediaRequest {
|
||||
} else {
|
||||
if (media.status === MediaStatus.BLACKLISTED) {
|
||||
logger.warn('Request for media blocked due to being blacklisted', {
|
||||
id: isLbAlbum(requestedMedia)
|
||||
? requestedMedia.release_group_mbid
|
||||
: requestedMedia.id,
|
||||
tmdbId: tmdbMedia.id,
|
||||
mediaType: requestBody.mediaType,
|
||||
label: 'Media Request',
|
||||
});
|
||||
@@ -204,21 +164,7 @@ export class MediaRequest {
|
||||
.leftJoin('request.media', 'media')
|
||||
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
||||
.andWhere(
|
||||
requestBody.mediaType === 'music'
|
||||
? 'media.mbId = :mbId'
|
||||
: 'media.tmdbId = :tmdbId',
|
||||
requestBody.mediaType === 'music'
|
||||
? {
|
||||
mbId: (requestedMedia as { release_group_mbid: string })
|
||||
.release_group_mbid,
|
||||
}
|
||||
: {
|
||||
tmdbId: isTmdbMedia(requestedMedia)
|
||||
? requestedMedia.id
|
||||
: undefined,
|
||||
}
|
||||
)
|
||||
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
||||
.andWhere('media.mediaType = :mediaType', {
|
||||
mediaType: requestBody.mediaType,
|
||||
})
|
||||
@@ -227,16 +173,12 @@ export class MediaRequest {
|
||||
if (existing && existing.length > 0) {
|
||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||
if (
|
||||
(requestBody.mediaType === MediaType.MOVIE ||
|
||||
requestBody.mediaType === MediaType.MUSIC) &&
|
||||
requestBody.mediaType === MediaType.MOVIE &&
|
||||
existing[0].status !== MediaRequestStatus.DECLINED &&
|
||||
existing[0].status !== MediaRequestStatus.COMPLETED
|
||||
) {
|
||||
logger.warn('Duplicate request for media blocked', {
|
||||
id:
|
||||
requestBody.mediaType === MediaType.MUSIC
|
||||
? media.mbId
|
||||
: (requestedMedia as TmdbMovieDetails | TmdbTvDetails).id,
|
||||
tmdbId: tmdbMedia.id,
|
||||
mediaType: requestBody.mediaType,
|
||||
is4k: requestBody.is4k,
|
||||
label: 'Media Request',
|
||||
@@ -276,78 +218,32 @@ export class MediaRequest {
|
||||
const defaultSonarrId = requestBody.is4k
|
||||
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
||||
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
|
||||
const defaultLidarrId = settings.lidarr.findIndex((l) => l.isDefault);
|
||||
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
const overrideRules = await overrideRuleRepository.find({
|
||||
where:
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? { radarrServiceId: defaultRadarrId }
|
||||
: requestBody.mediaType === MediaType.TV
|
||||
? { sonarrServiceId: defaultSonarrId }
|
||||
: { lidarrServiceId: defaultLidarrId },
|
||||
: { sonarrServiceId: defaultSonarrId },
|
||||
});
|
||||
|
||||
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||
// Only apply keyword/genre rules for TMDB media
|
||||
if (isTmdbMedia(requestedMedia)) {
|
||||
const hasAnimeKeyword =
|
||||
'results' in requestedMedia.keywords &&
|
||||
requestedMedia.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
);
|
||||
const hasAnimeKeyword =
|
||||
'results' in tmdbMedia.keywords &&
|
||||
tmdbMedia.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
);
|
||||
|
||||
if (
|
||||
requestBody.mediaType === MediaType.TV &&
|
||||
hasAnimeKeyword &&
|
||||
(!rule.keywords ||
|
||||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.genre &&
|
||||
!rule.genre
|
||||
.split(',')
|
||||
.some((genreId) =>
|
||||
requestedMedia.genres
|
||||
.map((genre) => genre.id)
|
||||
.includes(Number(genreId))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.language &&
|
||||
!rule.language
|
||||
.split('|')
|
||||
.some(
|
||||
(languageId) => languageId === requestedMedia.original_language
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.keywords &&
|
||||
!rule.keywords.split(',').some((keywordId) => {
|
||||
let keywordList: TmdbKeyword[] = [];
|
||||
|
||||
if ('keywords' in requestedMedia.keywords) {
|
||||
keywordList = requestedMedia.keywords.keywords;
|
||||
} else if ('results' in requestedMedia.keywords) {
|
||||
keywordList = requestedMedia.keywords.results;
|
||||
}
|
||||
|
||||
return keywordList
|
||||
.map((keyword: TmdbKeyword) => keyword.id)
|
||||
.includes(Number(keywordId));
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Skip override rules if the media is an anime TV show as anime TV
|
||||
// is handled by default and override rules do not explicitly include
|
||||
// the anime keyword
|
||||
if (
|
||||
requestBody.mediaType === MediaType.TV &&
|
||||
hasAnimeKeyword &&
|
||||
(!rule.keywords ||
|
||||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -358,7 +254,44 @@ export class MediaRequest {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.genre &&
|
||||
!rule.genre
|
||||
.split(',')
|
||||
.some((genreId) =>
|
||||
tmdbMedia.genres
|
||||
.map((genre) => genre.id)
|
||||
.includes(Number(genreId))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.language &&
|
||||
!rule.language
|
||||
.split('|')
|
||||
.some((languageId) => languageId === tmdbMedia.original_language)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.keywords &&
|
||||
!rule.keywords.split(',').some((keywordId) => {
|
||||
let keywordList: TmdbKeyword[] = [];
|
||||
|
||||
if ('keywords' in tmdbMedia.keywords) {
|
||||
keywordList = tmdbMedia.keywords.keywords;
|
||||
} else if ('results' in tmdbMedia.keywords) {
|
||||
keywordList = tmdbMedia.keywords.results;
|
||||
}
|
||||
|
||||
return keywordList
|
||||
.map((keyword: TmdbKeyword) => keyword.id)
|
||||
.includes(Number(keywordId));
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -400,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,
|
||||
@@ -441,47 +380,10 @@ export class MediaRequest {
|
||||
isAutoRequest: options.isAutoRequest ?? false,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return request;
|
||||
} else if (requestBody.mediaType === MediaType.MUSIC) {
|
||||
await mediaRepository.save(media);
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.MUSIC,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the music auto approve permission, automatically approve the request
|
||||
status: user.hasPermission(
|
||||
[
|
||||
Permission.AUTO_APPROVE,
|
||||
Permission.AUTO_APPROVE_MUSIC,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: user.hasPermission(
|
||||
[
|
||||
Permission.AUTO_APPROVE,
|
||||
Permission.AUTO_APPROVE_MUSIC,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? user
|
||||
: undefined,
|
||||
serverId: requestBody.serverId,
|
||||
profileId: profileId,
|
||||
rootFolder: rootFolder,
|
||||
tags: tags,
|
||||
isAutoRequest: options.isAutoRequest ?? false,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return request;
|
||||
} else {
|
||||
const tmdbMediaShow = requestedMedia as Awaited<
|
||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||
ReturnType<typeof tmdb.getTvShow>
|
||||
>;
|
||||
let requestedSeasons =
|
||||
@@ -547,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,
|
||||
@@ -624,6 +532,7 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'mediaId' })
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
@@ -820,17 +729,9 @@ export class MediaRequest {
|
||||
type: Notification
|
||||
) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const coverArt = new CoverArtArchive();
|
||||
const musicbrainz = new MusicBrainz();
|
||||
|
||||
try {
|
||||
const mediaType =
|
||||
entity.type === MediaType.MOVIE
|
||||
? 'Movie'
|
||||
: entity.type === MediaType.TV
|
||||
? 'Series'
|
||||
: 'Album';
|
||||
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||
let event: string | undefined;
|
||||
let notifyAdmin = true;
|
||||
let notifySystem = true;
|
||||
@@ -910,34 +811,6 @@ export class MediaRequest {
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (entity.type === MediaType.MUSIC && media.mbId) {
|
||||
const album = await listenbrainz.getAlbum(media.mbId);
|
||||
const coverArtResponse = await coverArt.getCoverArt(media.mbId);
|
||||
const coverArtUrl =
|
||||
coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||
const artistId =
|
||||
album.release_group_metadata?.artist?.artists[0]?.artist_mbid;
|
||||
const artistWiki = artistId
|
||||
? await musicbrainz.getArtistWikipediaExtract({
|
||||
artistMbid: artistId,
|
||||
})
|
||||
: null;
|
||||
|
||||
notificationManager.sendNotification(type, {
|
||||
media,
|
||||
request: entity,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||
event,
|
||||
subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`,
|
||||
message: truncate(artistWiki?.content ?? '', {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
image: coverArtUrl,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
class MetadataAlbum {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
public mbAlbumId: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public caaUrl: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<MetadataAlbum>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default MetadataAlbum;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
class MetadataArtist {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
public mbArtistId: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public tmdbPersonId: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public tmdbThumb: string | null;
|
||||
|
||||
@DbAwareColumn({ nullable: true, type: 'datetime' })
|
||||
public tmdbUpdatedAt: Date | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public tadbThumb: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public tadbCover: string | null;
|
||||
|
||||
@DbAwareColumn({ nullable: true, type: 'datetime' })
|
||||
public tadbUpdatedAt: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
constructor(init?: Partial<MetadataArtist>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default MetadataArtist;
|
||||
@@ -12,9 +12,6 @@ class OverrideRule {
|
||||
@Column({ type: 'int', nullable: true })
|
||||
public sonarrServiceId?: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
public lidarrServiceId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public users?: string;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -124,12 +124,6 @@ export class User {
|
||||
@Column({ nullable: true })
|
||||
public tvQuotaDays?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public musicQuotaLimit?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public musicQuotaDays?: number;
|
||||
|
||||
@OneToOne(() => UserSettings, (settings) => settings.user, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
@@ -277,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;
|
||||
|
||||
@@ -301,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;
|
||||
|
||||
@@ -340,30 +334,6 @@ export class User {
|
||||
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
|
||||
: 0;
|
||||
|
||||
const musicQuotaLimit = !canBypass
|
||||
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
|
||||
: 0;
|
||||
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
|
||||
|
||||
// Count music requests made during quota period
|
||||
const musicDate = new Date();
|
||||
if (musicQuotaDays) {
|
||||
musicDate.setDate(musicDate.getDate() - musicQuotaDays);
|
||||
}
|
||||
|
||||
const musicQuotaUsed = musicQuotaLimit
|
||||
? await requestRepository.count({
|
||||
where: {
|
||||
requestedBy: {
|
||||
id: this.id,
|
||||
},
|
||||
createdAt: AfterDate(musicDate),
|
||||
type: MediaType.MUSIC,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
|
||||
return {
|
||||
movie: {
|
||||
days: movieQuotaDays,
|
||||
@@ -387,18 +357,6 @@ export class User {
|
||||
restricted:
|
||||
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
|
||||
},
|
||||
music: {
|
||||
days: musicQuotaDays,
|
||||
limit: musicQuotaLimit,
|
||||
used: musicQuotaUsed,
|
||||
remaining: musicQuotaLimit
|
||||
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
|
||||
: undefined,
|
||||
restricted:
|
||||
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
|
||||
? true
|
||||
: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,7 +26,6 @@ export class NotFoundError extends Error {
|
||||
|
||||
@Entity()
|
||||
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
||||
@Unique('UNIQUE_USER_FOREIGN', ['mbId', 'requestedBy'])
|
||||
export class Watchlist implements WatchlistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@@ -40,13 +39,9 @@ export class Watchlist implements WatchlistItem {
|
||||
@Column({ type: 'varchar' })
|
||||
title = '';
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public mbId?: string;
|
||||
public tmdbId: number;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.watchlists, {
|
||||
eager: true,
|
||||
@@ -57,7 +52,6 @@ export class Watchlist implements WatchlistItem {
|
||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
nullable: false,
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@@ -83,8 +77,7 @@ export class Watchlist implements WatchlistItem {
|
||||
mediaType: MediaType;
|
||||
ratingKey?: ZodOptional<ZodString>['_output'];
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId?: ZodNumber['_output'];
|
||||
mbId?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
};
|
||||
user: User;
|
||||
}): Promise<Watchlist> {
|
||||
@@ -92,88 +85,46 @@ export class Watchlist implements WatchlistItem {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
let media: Media | null;
|
||||
const tmdbMedia =
|
||||
watchlistRequest.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||
|
||||
if (watchlistRequest.mediaType === MediaType.MUSIC) {
|
||||
if (!watchlistRequest.mbId) {
|
||||
throw new Error('MusicBrainz ID is required for music media type');
|
||||
}
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
})
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.mbId = :mbId', { mbId: watchlistRequest.mbId })
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
mbId: watchlistRequest.mbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
media = await mediaRepository.findOne({
|
||||
where: { mbId: watchlistRequest.mbId, mediaType: MediaType.MUSIC },
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
mbId: watchlistRequest.mbId,
|
||||
mediaType: MediaType.MUSIC,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For movies/TV, validate tmdbId exists
|
||||
if (!watchlistRequest.tmdbId) {
|
||||
throw new Error('TMDB ID is required for movie/TV media types');
|
||||
}
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
const tmdbMedia =
|
||||
watchlistRequest.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
},
|
||||
});
|
||||
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
})
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
},
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const watchlist = new this({
|
||||
@@ -188,19 +139,14 @@ export class Watchlist implements WatchlistItem {
|
||||
}
|
||||
|
||||
public static async deleteWatchlist(
|
||||
id: Watchlist['tmdbId'] | Watchlist['mbId'],
|
||||
tmdbId: Watchlist['tmdbId'],
|
||||
user: User
|
||||
): Promise<Watchlist | null> {
|
||||
const watchlistRepository = getRepository(this);
|
||||
|
||||
// Check if the ID is a number (TMDB) or string (MusicBrainz)
|
||||
const whereClause =
|
||||
typeof id === 'number'
|
||||
? { tmdbId: id, requestedBy: { id: user.id } }
|
||||
: { mbId: id, requestedBy: { id: user.id } };
|
||||
|
||||
const watchlist = await watchlistRepository.findOneBy(whereClause);
|
||||
|
||||
const watchlist = await watchlistRepository.findOneBy({
|
||||
tmdbId,
|
||||
requestedBy: { id: user.id },
|
||||
});
|
||||
if (!watchlist) {
|
||||
throw new NotFoundError('not Found');
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@ import logger from '@server/logger';
|
||||
import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import avatarproxy from '@server/routes/avatarproxy';
|
||||
import caaproxy from '@server/routes/caaproxy';
|
||||
import tadbproxy from '@server/routes/tadbproxy';
|
||||
import tmdbproxy from '@server/routes/tmdbproxy';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
@@ -99,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
|
||||
@@ -237,10 +238,8 @@ app
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/tmdbproxy', clearCookies, tmdbproxy);
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||
server.use('/caaproxy', clearCookies, caaproxy);
|
||||
server.use('/tadbproxy', clearCookies, tadbproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
||||
|
||||
export interface BlacklistItem {
|
||||
tmdbId?: number;
|
||||
mbId?: string;
|
||||
mediaType: 'movie' | 'tv' | 'music';
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title?: string;
|
||||
createdAt?: Date;
|
||||
user?: User;
|
||||
|
||||
@@ -7,9 +7,8 @@ export interface GenreSliderItem {
|
||||
export interface WatchlistItem {
|
||||
id: number;
|
||||
ratingKey: string;
|
||||
tmdbId?: number;
|
||||
mbId?: string;
|
||||
mediaType: 'movie' | 'tv' | 'music';
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
name: string;
|
||||
is4k?: boolean;
|
||||
is4k: boolean;
|
||||
isDefault: boolean;
|
||||
activeProfileId: number;
|
||||
activeDirectory: string;
|
||||
|
||||
@@ -64,10 +64,7 @@ export interface CacheItem {
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<
|
||||
'tmdb' | 'avatar' | 'caa' | 'tadb',
|
||||
{ size: number; imageCount: number }
|
||||
>;
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
dnsCache: {
|
||||
stats: DnsStats | undefined;
|
||||
entries: DnsEntries | undefined;
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface QuotaStatus {
|
||||
export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
music: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface UserWatchDataResponse {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const watchlistCreate = z
|
||||
.object({
|
||||
ratingKey: z.coerce.string().optional(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ tmdbId: z.coerce.number() }),
|
||||
z.object({ mbId: z.coerce.string() }),
|
||||
])
|
||||
);
|
||||
export const watchlistCreate = z.object({
|
||||
ratingKey: z.coerce.string().optional(),
|
||||
tmdbId: z.coerce.number(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
jellyfinFullScanner,
|
||||
jellyfinRecentScanner,
|
||||
} from '@server/lib/scanners/jellyfin';
|
||||
import { lidarrScanner } from '@server/lib/scanners/lidarr';
|
||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||
@@ -173,21 +172,6 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => sonarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full lidarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'lidarr-scan',
|
||||
name: 'Lidarr Scan',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['lidarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['lidarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: lidarr Scan', { label: 'Jobs' });
|
||||
lidarrScanner.run();
|
||||
}),
|
||||
running: () => lidarrScanner.status().running,
|
||||
cancelFn: () => lidarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Checks if media is still available in plex/sonarr/radarr libs
|
||||
scheduledJobs.push({
|
||||
id: 'availability-sync',
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import type { PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import LidarrAPI, { type LidarrAlbum } from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
@@ -13,11 +12,7 @@ import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import type Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
LidarrSettings,
|
||||
RadarrSettings,
|
||||
SonarrSettings,
|
||||
} from '@server/lib/settings';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
@@ -33,7 +28,6 @@ class AvailabilitySync {
|
||||
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||
private radarrServers: RadarrSettings[];
|
||||
private sonarrServers: SonarrSettings[];
|
||||
private lidarrServers: LidarrSettings[];
|
||||
|
||||
async run() {
|
||||
const settings = getSettings();
|
||||
@@ -44,7 +38,6 @@ class AvailabilitySync {
|
||||
this.sonarrSeasonsCache = {};
|
||||
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||
this.lidarrServers = settings.lidarr.filter((server) => server.syncEnabled);
|
||||
|
||||
try {
|
||||
logger.info(`Starting availability sync...`, {
|
||||
@@ -307,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) =>
|
||||
@@ -318,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) =>
|
||||
@@ -370,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 (
|
||||
@@ -458,47 +397,6 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (media.mediaType === 'music') {
|
||||
let musicExists = false;
|
||||
|
||||
const existsInLidarr = await this.mediaExistsInLidarr(media);
|
||||
|
||||
// Check media server existence (Plex/Jellyfin/Emby)
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||
if (existsInPlex || existsInLidarr) {
|
||||
musicExists = true;
|
||||
logger.info(
|
||||
`The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
const { existsInJellyfin } = await this.mediaExistsInJellyfin(
|
||||
media,
|
||||
false
|
||||
);
|
||||
if (existsInJellyfin || existsInLidarr) {
|
||||
musicExists = true;
|
||||
logger.info(
|
||||
`The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!musicExists && media.status === MediaStatus.AVAILABLE) {
|
||||
await this.mediaUpdater(media, false, mediaServerType);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error('Failed to complete availability sync.', {
|
||||
@@ -606,29 +504,17 @@ class AvailabilitySync {
|
||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||
: null;
|
||||
}
|
||||
|
||||
// Update log message to include music media type
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}] was not found in any ${
|
||||
media.mediaType === 'movie'
|
||||
? 'Radarr'
|
||||
: media.mediaType === 'tv'
|
||||
? 'Sonarr'
|
||||
: 'Lidarr'
|
||||
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||
} and ${
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
@@ -637,14 +523,8 @@ class AvailabilitySync {
|
||||
} catch (ex) {
|
||||
logger.debug(
|
||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}].`,
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}].`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
@@ -708,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' }
|
||||
);
|
||||
@@ -732,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(
|
||||
@@ -762,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')) {
|
||||
@@ -778,6 +672,8 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr) break;
|
||||
}
|
||||
|
||||
return existsInRadarr;
|
||||
@@ -904,51 +800,6 @@ class AvailabilitySync {
|
||||
return seasonExists;
|
||||
}
|
||||
|
||||
private async mediaExistsInLidarr(media: Media): Promise<boolean> {
|
||||
let existsInLidarr = false;
|
||||
|
||||
// Check for availability in all configured Lidarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.lidarrServers) {
|
||||
const lidarrAPI = new LidarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||
});
|
||||
|
||||
try {
|
||||
let lidarr: LidarrAlbum | undefined;
|
||||
|
||||
if (media.externalServiceId) {
|
||||
lidarr = await lidarrAPI.getAlbum({
|
||||
id: media.externalServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
lidarr?.statistics &&
|
||||
lidarr.statistics.totalTrackCount > 0 &&
|
||||
lidarr.statistics.trackFileCount === lidarr.statistics.totalTrackCount
|
||||
) {
|
||||
existsInLidarr = true;
|
||||
break;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInLidarr = true;
|
||||
logger.debug(
|
||||
`Failed to retrieve album [Foreign ID ${media.mbId}] from Lidarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return existsInLidarr;
|
||||
}
|
||||
|
||||
// Plex
|
||||
private async mediaExistsInPlex(
|
||||
media: Media,
|
||||
@@ -981,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) {
|
||||
@@ -992,14 +887,8 @@ class AvailabilitySync {
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}] from Plex.`,
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
@@ -1111,18 +1000,12 @@ class AvailabilitySync {
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||
existsInJellyfin = false;
|
||||
existsInJellyfin = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}] from Jellyfin.`,
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
|
||||
@@ -2,13 +2,8 @@ import NodeCache from 'node-cache';
|
||||
|
||||
export type AvailableCacheIds =
|
||||
| 'tmdb'
|
||||
| 'musicbrainz'
|
||||
| 'listenbrainz'
|
||||
| 'covertartarchive'
|
||||
| 'tadb'
|
||||
| 'radarr'
|
||||
| 'sonarr'
|
||||
| 'lidarr'
|
||||
| 'rt'
|
||||
| 'imdb'
|
||||
| 'github'
|
||||
@@ -53,25 +48,8 @@ class CacheManager {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
listenbrainz: new Cache('listenbrainz', 'ListenBrainz API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
covertartarchive: new Cache('covertartarchive', 'CovertArtArchive API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
tadb: new Cache('tadb', 'The Audio Database API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
radarr: new Cache('radarr', 'Radarr API'),
|
||||
sonarr: new Cache('sonarr', 'Sonarr API'),
|
||||
lidarr: new Cache('lidarr', 'Lidarr API'),
|
||||
rt: new Cache('rt', 'Rotten Tomatoes API', {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
@@ -28,7 +27,6 @@ export interface DownloadingItem {
|
||||
class DownloadTracker {
|
||||
private radarrServers: Record<number, DownloadingItem[]> = {};
|
||||
private sonarrServers: Record<number, DownloadingItem[]> = {};
|
||||
private lidarrServers: Record<number, DownloadingItem[]> = {};
|
||||
|
||||
public getMovieProgress(
|
||||
serverId: number,
|
||||
@@ -56,19 +54,6 @@ class DownloadTracker {
|
||||
);
|
||||
}
|
||||
|
||||
public getMusicProgress(
|
||||
serverId: number,
|
||||
externalServiceId: number
|
||||
): DownloadingItem[] {
|
||||
if (!this.lidarrServers[serverId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.lidarrServers[serverId].filter(
|
||||
(item) => item.externalId === externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public async resetDownloadTracker() {
|
||||
this.radarrServers = {};
|
||||
this.sonarrServers = {};
|
||||
@@ -77,7 +62,6 @@ class DownloadTracker {
|
||||
public updateDownloads() {
|
||||
this.updateRadarrDownloads();
|
||||
this.updateSonarrDownloads();
|
||||
this.updateLidarrDownloads();
|
||||
}
|
||||
|
||||
private async updateRadarrDownloads() {
|
||||
@@ -236,84 +220,6 @@ class DownloadTracker {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async updateLidarrDownloads() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Remove duplicate servers
|
||||
const filteredServers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
|
||||
return (
|
||||
lidarrA.hostname === lidarrB.hostname &&
|
||||
lidarrA.port === lidarrB.port &&
|
||||
lidarrA.baseUrl === lidarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Lidarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const lidarr = new LidarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||
});
|
||||
|
||||
try {
|
||||
await lidarr.refreshMonitoredDownloads();
|
||||
const queueItems = await lidarr.getQueue();
|
||||
|
||||
this.lidarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.albumId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MUSIC,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
downloadId: item.downloadId,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Lidarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`Unable to get queue from Lidarr server: ${server.name}`,
|
||||
{
|
||||
label: 'Download Tracker',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.lidarr.filter(
|
||||
(ls) =>
|
||||
ls.hostname === server.hostname &&
|
||||
ls.port === server.port &&
|
||||
ls.baseUrl === server.baseUrl &&
|
||||
ls.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
logger.debug(
|
||||
`Matching download data to ${matchingServers.length} other Lidarr server(s)`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
matchingServers.forEach((ms) => {
|
||||
if (ms.syncEnabled) {
|
||||
this.lidarrServers[ms.id] = this.lidarrServers[server.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTracker = new DownloadTracker();
|
||||
|
||||
@@ -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';
|
||||
@@ -71,9 +71,7 @@ class EmailAgent
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: payload.media.mediaType === MediaType.TV
|
||||
? 'series'
|
||||
: 'album'
|
||||
: 'series'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
@@ -115,11 +113,7 @@ class EmailAgent
|
||||
body = `A request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}failed to be added to ${
|
||||
payload.media?.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: payload.media?.mediaType === MediaType.TV
|
||||
? 'Sonarr'
|
||||
: 'Lidarr'
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
|
||||
}:`;
|
||||
break;
|
||||
}
|
||||
@@ -141,11 +135,7 @@ class EmailAgent
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.request.requestedBy.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${
|
||||
payload.media?.mediaType === MediaType.MUSIC
|
||||
? payload.media?.mbId
|
||||
: payload.media?.tmdbId
|
||||
}`
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
|
||||
@@ -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
|
||||
@@ -47,9 +56,7 @@ class WebPushAgent
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: payload.media.mediaType === MediaType.TV
|
||||
? 'series'
|
||||
: 'album'
|
||||
: 'series'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
@@ -121,10 +128,8 @@ class WebPushAgent
|
||||
const actionUrl = payload.issue
|
||||
? `/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? payload.media.mediaType === MediaType.MUSIC
|
||||
? `/music/${payload.media.mbId}`
|
||||
: `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
const actionUrlTitle = actionUrl
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'}`
|
||||
@@ -192,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ export enum Permission {
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
MANAGE_BLACKLIST = 268435456,
|
||||
VIEW_BLACKLIST = 1073741824,
|
||||
AUTO_APPROVE_MUSIC = 2147483648,
|
||||
REQUEST_MUSIC = 4294967296,
|
||||
AUTO_REQUEST_MUSIC = 8589934592,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -28,13 +28,14 @@ export interface MediaIds {
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
isHama?: boolean;
|
||||
mbId?: string;
|
||||
}
|
||||
|
||||
interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -80,24 +81,11 @@ class BaseScanner<T> {
|
||||
this.updateRate = updateRate ?? UPDATE_RATE;
|
||||
}
|
||||
|
||||
private async getExisting(
|
||||
id: number | string,
|
||||
mediaType: MediaType
|
||||
): Promise<Media | null> {
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const query: Record<string, any> = {
|
||||
mediaType,
|
||||
};
|
||||
|
||||
if (mediaType === MediaType.MUSIC) {
|
||||
query.mbId = id.toString();
|
||||
} else {
|
||||
query.tmdbId = Number(id);
|
||||
}
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: query,
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
@@ -109,6 +97,8 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -125,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;
|
||||
}
|
||||
@@ -147,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
|
||||
@@ -187,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;
|
||||
@@ -217,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}`);
|
||||
}
|
||||
@@ -235,11 +250,12 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -271,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;
|
||||
}
|
||||
@@ -285,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
|
||||
@@ -294,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 =
|
||||
@@ -309,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({
|
||||
@@ -323,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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -443,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 {
|
||||
@@ -505,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}`);
|
||||
@@ -540,93 +599,6 @@ class BaseScanner<T> {
|
||||
});
|
||||
}
|
||||
|
||||
protected async processMusic(
|
||||
mbId: string,
|
||||
{
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
processing = false,
|
||||
title = 'Unknown Title',
|
||||
}: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(mbId, async () => {
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { mbId, mediaType: MediaType.MUSIC },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const newMedia = new Media();
|
||||
newMedia.mbId = mbId;
|
||||
newMedia.status = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MUSIC;
|
||||
newMedia.mediaAddedAt = mediaAddedAt ?? newMedia.mediaAddedAt;
|
||||
newMedia.ratingKey = ratingKey ?? newMedia.ratingKey;
|
||||
newMedia.serviceId = serviceId ?? newMedia.serviceId;
|
||||
newMedia.externalServiceId =
|
||||
externalServiceId ?? newMedia.externalServiceId;
|
||||
newMedia.externalServiceSlug =
|
||||
externalServiceSlug ?? newMedia.externalServiceSlug;
|
||||
|
||||
try {
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
} catch (err) {
|
||||
this.log('Failed to save new media', 'error', {
|
||||
title,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let hasChanges = false;
|
||||
|
||||
if (existing.status !== MediaStatus.AVAILABLE && !processing) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (serviceId && !existing.serviceId) {
|
||||
existing.serviceId = serviceId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (externalServiceId && !existing.externalServiceId) {
|
||||
existing.externalServiceId = externalServiceId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (externalServiceSlug && !existing.externalServiceSlug) {
|
||||
existing.externalServiceSlug = externalServiceSlug;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (mediaAddedAt && !existing.mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (ratingKey && !existing.ratingKey) {
|
||||
existing.ratingKey = ratingKey;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
try {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(`Updated existing media: ${title}`);
|
||||
} catch (err) {
|
||||
this.log('Failed to update existing media', 'error', {
|
||||
title,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call startRun from child class whenever a run is starting to
|
||||
* ensure required values are set
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
import type { LidarrAlbum } from '@server/api/servarr/lidarr';
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import type {
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type { LidarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: LidarrSettings;
|
||||
servers: LidarrSettings[];
|
||||
};
|
||||
|
||||
class LidarrScanner
|
||||
extends BaseScanner<LidarrAlbum>
|
||||
implements RunnableScanner<SyncStatus>
|
||||
{
|
||||
private servers: LidarrSettings[];
|
||||
private currentServer: LidarrSettings;
|
||||
private lidarrApi: LidarrAPI;
|
||||
|
||||
constructor() {
|
||||
super('Lidarr Scan', { bundleSize: 50 });
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.servers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
|
||||
return (
|
||||
lidarrA.hostname === lidarrB.hostname &&
|
||||
lidarrA.port === lidarrB.port &&
|
||||
lidarrA.baseUrl === lidarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Lidarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.lidarrApi = new LidarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||
});
|
||||
|
||||
this.items = await this.lidarrApi.getAlbums();
|
||||
await this.loop(this.processLidarrAlbum.bind(this), { sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Lidarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Lidarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processLidarrAlbum(lidarrAlbum: LidarrAlbum): Promise<void> {
|
||||
try {
|
||||
if (!lidarrAlbum.monitored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mbId = lidarrAlbum.foreignAlbumId;
|
||||
if (!mbId) {
|
||||
this.log(
|
||||
'No MusicBrainz ID found for this title. Skipping item.',
|
||||
'debug',
|
||||
{
|
||||
title: lidarrAlbum.title,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processMusic(mbId, {
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: lidarrAlbum.id,
|
||||
externalServiceSlug: mbId,
|
||||
title: lidarrAlbum.title,
|
||||
processing:
|
||||
lidarrAlbum.monitored &&
|
||||
(!lidarrAlbum.statistics ||
|
||||
lidarrAlbum.statistics.trackFileCount <
|
||||
lidarrAlbum.statistics.totalTrackCount),
|
||||
});
|
||||
} catch (e) {
|
||||
this.log('Failed to process Lidarr media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: lidarrAlbum.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lidarrScanner = new LidarrScanner();
|
||||
@@ -1,6 +1,5 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
@@ -27,7 +26,6 @@ const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const mbRegex = new RegExp(/mbid:\/\/([0-9a-f-]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
// Hama agent uses ASS naming, see details here:
|
||||
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
|
||||
@@ -97,7 +95,6 @@ class PlexScanner
|
||||
'info',
|
||||
{ lastScan: library.lastScan }
|
||||
);
|
||||
const mappedType = library.type === 'music' ? 'album' : library.type;
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id,
|
||||
library.lastScan
|
||||
@@ -106,7 +103,7 @@ class PlexScanner
|
||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||
}
|
||||
: undefined,
|
||||
mappedType
|
||||
library.type
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
@@ -218,12 +215,6 @@ class PlexScanner
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processPlexShow(plexitem);
|
||||
} else if (
|
||||
plexitem.type === 'artist' ||
|
||||
plexitem.type === 'album' ||
|
||||
plexitem.type === 'track'
|
||||
) {
|
||||
await this.processPlexMusic(plexitem);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to process Plex media', 'error', {
|
||||
@@ -390,60 +381,6 @@ class PlexScanner
|
||||
}
|
||||
}
|
||||
|
||||
private async processPlexMusic(plexitem: PlexLibraryItem) {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey;
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await this.plexClient.getMetadata(ratingKey, {
|
||||
includeChildren: true,
|
||||
});
|
||||
|
||||
if (metadata.Children?.Metadata) {
|
||||
const musicBrainz = new MusicBrainz();
|
||||
|
||||
for (const album of metadata.Children.Metadata) {
|
||||
const albumMetadata = await this.plexClient.getMetadata(
|
||||
album.ratingKey
|
||||
);
|
||||
|
||||
const mbReleaseId = albumMetadata.Guid?.find((g) => {
|
||||
const id = g.id.toLowerCase();
|
||||
return id.startsWith('mbid://');
|
||||
})?.id.replace('mbid://', '');
|
||||
|
||||
if (!mbReleaseId) {
|
||||
this.log('No MusicBrainz ID found for album', 'debug', {
|
||||
title: album.title,
|
||||
artist: metadata.title,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const releaseGroupId = await musicBrainz.getReleaseGroup({
|
||||
releaseId: mbReleaseId,
|
||||
});
|
||||
|
||||
if (releaseGroupId) {
|
||||
await this.processMusic(releaseGroupId, {
|
||||
mediaAddedAt: new Date(album.addedAt * 1000),
|
||||
ratingKey: album.ratingKey,
|
||||
title: album.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to process music media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: metadata?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
|
||||
let mediaIds: Partial<MediaIds> = {};
|
||||
// Check if item is using new plex movie/tv agent
|
||||
@@ -482,8 +419,6 @@ class PlexScanner
|
||||
} else if (ref.id.match(tvdbRegex)) {
|
||||
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
|
||||
mediaIds.tvdbId = Number(tvdbMatch);
|
||||
} else if (ref.id.match(mbRegex)) {
|
||||
mediaIds.mbId = ref.id.match(mbRegex)?.[1] ?? undefined;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -599,12 +534,6 @@ class PlexScanner
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for MusicBrainz
|
||||
} else if (plexitem.guid.match(mbRegex)) {
|
||||
const mbMatch = plexitem.guid.match(mbRegex);
|
||||
if (mbMatch) {
|
||||
mediaIds.mbId = mbMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaIds.tmdbId) {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import type {
|
||||
MbAlbumResult,
|
||||
MbArtistResult,
|
||||
} from '@server/api/musicbrainz/interfaces';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
TmdbPersonResult,
|
||||
TmdbSearchMovieResponse,
|
||||
TmdbSearchMultiResponse,
|
||||
TmdbSearchTvResponse,
|
||||
TmdbTvDetails,
|
||||
TmdbTvResult,
|
||||
@@ -26,19 +21,6 @@ import {
|
||||
isTvDetails,
|
||||
} from '@server/utils/typeHelpers';
|
||||
|
||||
export type CombinedSearchResponse = {
|
||||
page: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
results: (
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[];
|
||||
};
|
||||
interface SearchProvider {
|
||||
pattern: RegExp;
|
||||
search: ({
|
||||
@@ -49,7 +31,7 @@ interface SearchProvider {
|
||||
id: string;
|
||||
language?: string;
|
||||
query?: string;
|
||||
}) => Promise<CombinedSearchResponse>;
|
||||
}) => Promise<TmdbSearchMultiResponse>;
|
||||
}
|
||||
|
||||
const searchProviders: SearchProvider[] = [];
|
||||
@@ -77,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)[] = [];
|
||||
|
||||
@@ -203,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)[] = [];
|
||||
|
||||
@@ -232,39 +212,3 @@ searchProviders.push({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
searchProviders.push({
|
||||
pattern: new RegExp(/(?<=musicbrainz:)/),
|
||||
search: async ({ query }) => {
|
||||
const musicbrainz = new MusicBrainz();
|
||||
|
||||
try {
|
||||
const albumResults = await musicbrainz.searchAlbum({
|
||||
query: query || '',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const results: CombinedSearchResponse['results'] = albumResults.map(
|
||||
(album) =>
|
||||
({
|
||||
...album,
|
||||
media_type: 'album',
|
||||
} as MbAlbumResult)
|
||||
);
|
||||
|
||||
return {
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_results: results.length,
|
||||
results,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_results: 0,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface Library {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: 'show' | 'movie' | 'music';
|
||||
type: 'show' | 'movie';
|
||||
lastScan?: number;
|
||||
}
|
||||
|
||||
@@ -95,11 +95,6 @@ export interface SonarrSettings extends DVRSettings {
|
||||
enableSeasonFolders: boolean;
|
||||
}
|
||||
|
||||
export interface LidarrSettings extends DVRSettings {
|
||||
activeMetadataProfileId?: number;
|
||||
activeMetadataProfileName?: string;
|
||||
}
|
||||
|
||||
interface Quota {
|
||||
quotaLimit?: number;
|
||||
quotaDays?: number;
|
||||
@@ -135,7 +130,6 @@ export interface MainSettings {
|
||||
defaultQuotas: {
|
||||
movie: Quota;
|
||||
tv: Quota;
|
||||
music: Quota;
|
||||
};
|
||||
hideAvailable: boolean;
|
||||
hideBlacklisted: boolean;
|
||||
@@ -346,7 +340,6 @@ export type JobId =
|
||||
| 'plex-refresh-token'
|
||||
| 'radarr-scan'
|
||||
| 'sonarr-scan'
|
||||
| 'lidarr-scan'
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'jellyfin-recently-added-scan'
|
||||
@@ -365,7 +358,6 @@ export interface AllSettings {
|
||||
tautulli: TautulliSettings;
|
||||
radarr: RadarrSettings[];
|
||||
sonarr: SonarrSettings[];
|
||||
lidarr: LidarrSettings[];
|
||||
public: PublicSettings;
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
@@ -395,7 +387,6 @@ class Settings {
|
||||
defaultQuotas: {
|
||||
movie: {},
|
||||
tv: {},
|
||||
music: {},
|
||||
},
|
||||
hideAvailable: false,
|
||||
hideBlacklisted: false,
|
||||
@@ -438,7 +429,6 @@ class Settings {
|
||||
anime: MetadataProviderType.TMDB,
|
||||
},
|
||||
radarr: [],
|
||||
lidarr: [],
|
||||
sonarr: [],
|
||||
public: {
|
||||
initialized: false,
|
||||
@@ -562,9 +552,6 @@ class Settings {
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'lidarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'availability-sync': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
@@ -662,14 +649,6 @@ class Settings {
|
||||
this.data.radarr = data;
|
||||
}
|
||||
|
||||
get lidarr(): LidarrSettings[] {
|
||||
return this.data.lidarr;
|
||||
}
|
||||
|
||||
set lidarr(data: LidarrSettings[]) {
|
||||
this.data.lidarr = data;
|
||||
}
|
||||
|
||||
get sonarr(): SonarrSettings[] {
|
||||
return this.data.sonarr;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMusicSupport1762648503371 implements MigrationInterface {
|
||||
name = 'AddMusicSupport1762648503371';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata_album" ("id" SERIAL NOT NULL, "mbAlbumId" character varying NOT NULL, "caaUrl" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_fb8eda254e560f96039f7a0d812" UNIQUE ("mbAlbumId"), CONSTRAINT "PK_02aaaa276bcc3de3ead4bd2b8f3" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata_artist" ("id" SERIAL NOT NULL, "mbArtistId" character varying NOT NULL, "tmdbPersonId" character varying, "tmdbThumb" character varying, "tmdbUpdatedAt" TIMESTAMP WITH TIME ZONE, "tadbThumb" character varying, "tadbCover" character varying, "tadbUpdatedAt" TIMESTAMP WITH TIME ZONE, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_bff8b9448b4a8a3af0f8957d4b7" UNIQUE ("mbArtistId"), CONSTRAINT "PK_06d683fc350297c5aef7f0fe5c4" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD "mbId" character varying`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "override_rule" ADD "lidarrServiceId" integer`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaLimit" integer`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaDays" integer`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD "mbId" character varying`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "media" ADD "mbId" character varying`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "mediaId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ALTER COLUMN "tmdbId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" DROP CONSTRAINT "UQ_6bbafa28411e6046421991ea21c"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "tmdbId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" SET DEFAULT '0'`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4f7c7041c1792b568be902f097" ON "blacklist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6c866e76dd595ad15b8c5bf9c1" ON "media" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD CONSTRAINT "UQ_30a2423945ffaeb135b518d074d" UNIQUE ("tmdbId", "mbId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" DROP CONSTRAINT "UQ_30a2423945ffaeb135b518d074d"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_FOREIGN"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6c866e76dd595ad15b8c5bf9c1"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4f7c7041c1792b568be902f097"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a40b88a30fc50cf10264e279c9"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" DROP DEFAULT`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "tmdbId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ALTER COLUMN "tmdbId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "mediaId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "media" DROP COLUMN "mbId"`);
|
||||
await queryRunner.query(`ALTER TABLE "blacklist" DROP COLUMN "mbId"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaDays"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaLimit"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "override_rule" DROP COLUMN "lidarrServiceId"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "watchlist" DROP COLUMN "mbId"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata_artist"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata_album"`);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user