mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
41 Commits
preview-dn
...
preview-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9074b111 | ||
|
|
f2bd0abcd8 | ||
|
|
872fc4581e | ||
|
|
53dc802696 | ||
|
|
764f7b4270 | ||
|
|
82c583974f | ||
|
|
b10c57ce43 | ||
|
|
efba847452 | ||
|
|
420e320f1f | ||
|
|
f427bc26df | ||
|
|
eec3319ba6 | ||
|
|
da57ccc5a7 | ||
|
|
e9db34ea07 | ||
|
|
1143f88b6e | ||
|
|
0b1ad8ca1c | ||
|
|
b209e7fdf9 | ||
|
|
fed772a8e7 | ||
|
|
f46a763152 | ||
|
|
c024799dae | ||
|
|
837629ec47 | ||
|
|
f045274a30 | ||
|
|
1518dc01e5 | ||
|
|
328517cc0a | ||
|
|
9e737576de | ||
|
|
cd479d0d17 | ||
|
|
e9f2f4490f | ||
|
|
d5bf17574f | ||
|
|
17172e93f9 | ||
|
|
4878722030 | ||
|
|
479be0daeb | ||
|
|
6245dae3b3 | ||
|
|
d82c6f6222 | ||
|
|
13fe4c890b | ||
|
|
22b2824441 | ||
|
|
368ecf8771 | ||
|
|
d3fd5028dc | ||
|
|
c0fd81a5f0 | ||
|
|
af7ceaf7a2 | ||
|
|
b4adfd2ffa | ||
|
|
5c1583cf56 | ||
|
|
66d4cd63bb |
@@ -642,6 +642,24 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sudo-kraken",
|
||||
"name": "Joe Harrison",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53116754?v=4",
|
||||
"profile": "https://sudo-kraken.github.io/docs/",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ale183",
|
||||
"name": "ale183",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8809439?v=4",
|
||||
"profile": "https://github.com/ale183",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -4,6 +4,7 @@
|
||||
|
||||
#### To-Dos
|
||||
|
||||
- [ ] Disclosed any use of AI (see our [policy](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice))
|
||||
- [ ] Successful build `pnpm build`
|
||||
- [ ] Translation keys `pnpm i18n:extract`
|
||||
- [ ] Database migration (if required)
|
||||
|
||||
154
.github/workflows/ci.yml
vendored
154
.github/workflows/ci.yml
vendored
@@ -7,6 +7,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -17,14 +25,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: sh
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -32,137 +43,144 @@ jobs:
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
HUSKY: 0
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Formatting
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
build:
|
||||
name: Build & Publish Docker Images
|
||||
name: Build (per-arch, native runners)
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
outputs:
|
||||
digest-amd64: ${{ steps.set_outputs.outputs.digest-amd64 }}
|
||||
digest-arm64: ${{ steps.set_outputs.outputs.digest-arm64 }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Commit timestamp
|
||||
id: ts
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set lower case owner name
|
||||
run: |
|
||||
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
fallenbagel/jellyseerr
|
||||
ghcr.io/${{ env.OWNER_LC }}/jellyseerr
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
push: false
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=develop
|
||||
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||
outputs: |
|
||||
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
|
||||
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true
|
||||
SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
provenance: false
|
||||
- name: Set outputs
|
||||
id: set_outputs
|
||||
run: |
|
||||
platform="${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}"
|
||||
echo "digest-${platform}=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT
|
||||
|
||||
merge_and_push:
|
||||
name: Create and Push Multi-arch Manifest
|
||||
publish:
|
||||
name: Publish multi-arch image
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Commit timestamp
|
||||
id: ts
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set lower case owner name
|
||||
run: |
|
||||
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker manifest create fallenbagel/jellyseerr:develop \
|
||||
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
|
||||
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
|
||||
docker manifest push fallenbagel/jellyseerr:develop
|
||||
|
||||
# GHCR manifest
|
||||
docker manifest create ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop \
|
||||
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
|
||||
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
|
||||
docker manifest push ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=develop
|
||||
type=sha
|
||||
labels: |
|
||||
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
|
||||
- name: Build & Push (multi-arch, single tag)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=develop
|
||||
SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: |
|
||||
type=gha,scope=linux/amd64
|
||||
type=gha,scope=linux/arm64
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: merge_and_push
|
||||
needs: publish
|
||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
if [[ ${array[@]} =~ ${{ needs.publish.result }} ]]; then
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
echo "status=${{ needs.publish.result }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
|
||||
29
.github/workflows/codeql.yml
vendored
29
.github/workflows/codeql.yml
vendored
@@ -3,39 +3,52 @@ name: 'CodeQL'
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
schedule:
|
||||
- cron: '50 7 * * 5'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript]
|
||||
|
||||
language: [actions, javascript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
20
.github/workflows/conflict_labeler.yml
vendored
20
.github/workflows/conflict_labeler.yml
vendored
@@ -2,18 +2,24 @@ name: Merge Conflict Labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
branches: [develop]
|
||||
|
||||
pull_request_target:
|
||||
branches:
|
||||
- develop
|
||||
types: [synchronize]
|
||||
branches: [develop]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Labeling
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'Fallenbagel/jellyseerr' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
38
.github/workflows/cypress.yml
vendored
38
.github/workflows/cypress.yml
vendored
@@ -2,26 +2,49 @@ name: Cypress Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
branches: ['*']
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
branches: [develop]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cypress-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cypress-run:
|
||||
runs-on: ubuntu-latest
|
||||
name: Cypress Run
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup cypress cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: 9
|
||||
path: ~/.cache/Cypress
|
||||
key: ${{ runner.os }}-cypress-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cypress-store-
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
@@ -36,6 +59,7 @@ jobs:
|
||||
# Fix test titles in cypress dashboard
|
||||
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||
|
||||
- name: Upload video files
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
40
.github/workflows/docs-deploy.yml
vendored
40
.github/workflows/docs-deploy.yml
vendored
@@ -8,24 +8,30 @@ on:
|
||||
- 'docs/**'
|
||||
- 'gen-docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docusaurus
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: sh
|
||||
@@ -46,38 +52,26 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
run: |
|
||||
cd gen-docs
|
||||
pnpm build
|
||||
working-directory: gen-docs
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload Build Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: gen-docs/build
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
concurrency: build-deploy-pages
|
||||
|
||||
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write # to deploy to Pages
|
||||
id-token: write # to verify the deployment originates from an appropriate source
|
||||
|
||||
# Deploy to the github-pages environment
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# - name: Download Build Artifact
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: docusaurus-build
|
||||
# path: gen-docs/build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
29
.github/workflows/helm.yml
vendored
29
.github/workflows/helm.yml
vendored
@@ -4,11 +4,21 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'charts/**'
|
||||
- '.github/workflows/release-charts.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: helm-charts
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
package-helm-chart:
|
||||
name: Package helm chart
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -19,6 +29,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@v4
|
||||
@@ -42,16 +53,11 @@ jobs:
|
||||
# get current version
|
||||
current_version=$(grep '^version:' "$chart_path/Chart.yaml" | awk '{print $2}')
|
||||
# try to get current release version
|
||||
set +e
|
||||
oras discover ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:${current_version}
|
||||
oras_exit_code=$?
|
||||
set -e
|
||||
|
||||
if [ $oras_exit_code -ne 0 ]; then
|
||||
if oras manifest fetch "ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:${current_version}" >/dev/null 2>&1; then
|
||||
echo "No version change for $chart_name. Skipping."
|
||||
else
|
||||
helm dependency build "$chart_path"
|
||||
helm package "$chart_path" --destination ./.cr-release-packages
|
||||
else
|
||||
echo "No version change for $chart_name. Skipping."
|
||||
fi
|
||||
else
|
||||
echo "Skipping $chart_name: Not a valid Helm chart"
|
||||
@@ -61,7 +67,7 @@ jobs:
|
||||
- name: Check if artifacts exist
|
||||
id: check-artifacts
|
||||
run: |
|
||||
if ls .cr-release-packages/* >/dev/null 2>&1; then
|
||||
if ls .cr-release-packages/*.tgz >/dev/null 2>&1; then
|
||||
echo "has_artifacts=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_artifacts=false" >> $GITHUB_OUTPUT
|
||||
@@ -77,7 +83,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish to ghcr.io
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: write # needed for pushing to github registry
|
||||
id-token: write # needed for signing the images with GitHub OIDC Token
|
||||
@@ -88,6 +94,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
29
.github/workflows/lint-helm-charts.yml
vendored
29
.github/workflows/lint-helm-charts.yml
vendored
@@ -7,27 +7,48 @@ on:
|
||||
paths:
|
||||
- '.github/workflows/lint-helm-charts.yml'
|
||||
- 'charts/**'
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'charts/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: charts-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.2.0
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2
|
||||
|
||||
- name: Ensure documentation is updated
|
||||
uses: docker://jnorwood/helm-docs:v1.14.2
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.6.1
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
run: |
|
||||
changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }})
|
||||
if [[ -n "$changed" ]]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "$changed"
|
||||
fi
|
||||
|
||||
- name: Run chart-testing
|
||||
if: steps.list-changed.outputs.changed == 'true'
|
||||
run: ct lint --target-branch ${{ github.event.repository.default_branch }} --validate-maintainers=false
|
||||
|
||||
130
.github/workflows/preview.yml
vendored
130
.github/workflows/preview.yml
vendored
@@ -4,28 +4,125 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'preview-*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: preview-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_and_push:
|
||||
name: Build & Publish Docker Preview Images
|
||||
runs-on: ubuntu-22.04
|
||||
build:
|
||||
name: Build (per-arch, native runners)
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Get the version
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Commit timestamp
|
||||
id: ts
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Derive preview version from tag
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VER="${TAG#preview-}"
|
||||
VER="${VER#v}"
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "Building preview version: ${VER}"
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=${{ steps.ver.outputs.version }}
|
||||
SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
provenance: false
|
||||
|
||||
publish:
|
||||
name: Publish multi-arch image
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Commit timestamp
|
||||
id: ts
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Derive preview version from tag
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VER="${TAG#preview-}"
|
||||
VER="${VER#v}"
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing preview version: ${VER}"
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=preview-${{ steps.ver.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=preview-${{ steps.ver.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
|
||||
- name: Build & Push (multi-arch, single tag)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -33,7 +130,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||
tags: |
|
||||
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}
|
||||
BUILD_VERSION=${{ steps.ver.outputs.version }}
|
||||
SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: |
|
||||
type=gha,scope=linux/amd64
|
||||
type=gha,scope=linux/arm64
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
230
.github/workflows/release.yml
vendored
230
.github/workflows/release.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: Jellyseer Release
|
||||
name: Jellyseerr Release
|
||||
|
||||
on: workflow_dispatch
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
semantic-release:
|
||||
@@ -8,38 +16,29 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HUSKY: 0
|
||||
outputs:
|
||||
new_release_published: ${{ steps.release.outputs.new_release_published }}
|
||||
new_release_version: ${{ steps.release.outputs.new_release_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GH_TOKEN }}
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: sh
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -47,77 +46,151 @@ jobs:
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
uses: cycjimmy/semantic-release-action@v5
|
||||
with:
|
||||
extra_plugins: |
|
||||
@semantic-release/git@10
|
||||
@semantic-release/changelog@6
|
||||
@codedependant/semantic-release-docker@5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: npx semantic-release
|
||||
|
||||
# build-snap:
|
||||
# name: Build Snap Package (${{ matrix.architecture }})
|
||||
# needs: semantic-release
|
||||
# runs-on: ubuntu-22.04
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# architecture:
|
||||
# - amd64
|
||||
# - arm64
|
||||
# steps:
|
||||
# - name: Checkout Code
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
# - name: Switch to main branch
|
||||
# run: git checkout main
|
||||
# - name: Pull latest changes
|
||||
# run: git pull
|
||||
# - name: Prepare
|
||||
# id: prepare
|
||||
# run: |
|
||||
# git fetch --prune --tags
|
||||
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
# echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
# else
|
||||
# echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
# fi
|
||||
# - name: Set Up QEMU
|
||||
# uses: docker/setup-qemu-action@v3
|
||||
# with:
|
||||
# image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
# - name: Build Snap Package
|
||||
# uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
# id: build
|
||||
# with:
|
||||
# architecture: ${{ matrix.architecture }}
|
||||
# - name: Upload Snap Package
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||
# path: ${{ steps.build.outputs.snap }}
|
||||
# - name: Review Snap Package
|
||||
# uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
# with:
|
||||
# snap: ${{ steps.build.outputs.snap }}
|
||||
# - name: Publish Snap Package
|
||||
# uses: snapcore/action-publish@v1
|
||||
# env:
|
||||
# SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
# with:
|
||||
# snap: ${{ steps.build.outputs.snap }}
|
||||
# release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
build:
|
||||
name: Build (per-arch, native runners)
|
||||
needs: semantic-release
|
||||
if: needs.semantic-release.outputs.new_release_published == 'true'
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Commit timestamp
|
||||
id: ts
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Warm cache (no push) — ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=${{ needs.semantic-release.outputs.new_release_version }}
|
||||
SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
provenance: false
|
||||
|
||||
publish:
|
||||
name: Publish multi-arch image
|
||||
needs: [semantic-release, build]
|
||||
if: needs.semantic-release.outputs.new_release_published == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Commit timestamp
|
||||
id: ts
|
||||
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.semantic-release.outputs.new_release_version }}
|
||||
labels: |
|
||||
org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
|
||||
- name: Build & Push (multi-arch, single tag)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=${{ needs.semantic-release.outputs.new_release_version }}
|
||||
SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: |
|
||||
type=gha,scope=linux/amd64
|
||||
type=gha,scope=linux/arm64
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
- name: Also tag :latest (non-pre-release only)
|
||||
shell: bash
|
||||
run: |
|
||||
VER="${{ needs.semantic-release.outputs.new_release_version }}"
|
||||
if [[ "$VER" != *"-"* ]]; then
|
||||
docker buildx imagetools create \
|
||||
-t ${{ github.repository }}:latest \
|
||||
${{ github.repository }}:${VER}
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:latest \
|
||||
ghcr.io/${{ github.repository }}:${VER}
|
||||
fi
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: semantic-release
|
||||
needs: publish
|
||||
if: always()
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
@@ -127,6 +200,7 @@ jobs:
|
||||
else
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
|
||||
94
.github/workflows/snap.yaml.disabled
vendored
94
.github/workflows/snap.yaml.disabled
vendored
@@ -1,94 +0,0 @@
|
||||
name: Publish Snap
|
||||
|
||||
# turn off edge snap builds temporarily and make it manual
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches:
|
||||
# - develop
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
jobs:
|
||||
name: Job Check
|
||||
runs-on: ubuntu-22.04
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.12.1
|
||||
with:
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: jobs
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Configure Git
|
||||
run: git config --add safe.directory /data/parts/jellyseerr/src
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build-snap
|
||||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
51
.github/workflows/support.yml
vendored
51
.github/workflows/support.yml
vendored
@@ -4,22 +4,53 @@ on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
permissions:
|
||||
issues: read
|
||||
|
||||
concurrency:
|
||||
group: support-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'support' || github.event.action == 'reopened'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
steps:
|
||||
- uses: dessant/support-requests@v4
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'support'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, we use the issue tracker exclusively
|
||||
- name: Label added, comment and close issue
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'support'
|
||||
shell: bash
|
||||
env:
|
||||
BODY: >
|
||||
:wave: @${{ env.ISSUE_AUTHOR }}, we use the issue tracker exclusively
|
||||
for bug reports and feature requests. However, this issue appears
|
||||
to be a support request. Please use our support channels
|
||||
to get help with Jellyseerr.
|
||||
|
||||
- [Discord](https://discord.gg/ckbvBtDJgC)
|
||||
run: |
|
||||
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
|
||||
retry gh issue comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true
|
||||
retry gh issue close "$NUMBER" -R "$GH_REPO" || true
|
||||
gh issue lock "$NUMBER" -R "$GH_REPO" -r "off_topic" || true
|
||||
|
||||
close-issue: true
|
||||
lock-issue: true
|
||||
issue-lock-reason: 'off-topic'
|
||||
- name: Reopened or label removed, unlock issue
|
||||
if: github.event.action == 'unlabeled' && github.event.label.name == 'support'
|
||||
shell: bash
|
||||
run: |
|
||||
retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; }
|
||||
retry gh issue reopen "$NUMBER" -R "$GH_REPO" || true
|
||||
gh issue unlock "$NUMBER" -R "$GH_REPO" || true
|
||||
|
||||
- name: Remove support label on manual reopen
|
||||
if: github.event.action == 'reopened'
|
||||
shell: bash
|
||||
run: |
|
||||
gh issue edit "$NUMBER" -R "$GH_REPO" --remove-label "support" || true
|
||||
gh issue unlock "$NUMBER" -R "$GH_REPO" || true
|
||||
|
||||
18
.github/workflows/test-docs-deploy.yml
vendored
18
.github/workflows/test-docs-deploy.yml
vendored
@@ -8,24 +8,32 @@ on:
|
||||
- 'docs/**'
|
||||
- 'gen-docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docs-pr-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-deploy:
|
||||
name: Test deployment
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: sh
|
||||
@@ -42,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd gen-docs
|
||||
cd gen-docs
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
|
||||
@@ -4,11 +4,16 @@ dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
public/
|
||||
!public/sw.js
|
||||
docs/
|
||||
!/public/
|
||||
/public/*
|
||||
!/public/sw.js
|
||||
|
||||
# helm charts
|
||||
**/charts
|
||||
|
||||
@@ -21,5 +21,11 @@ module.exports = {
|
||||
rangeEnd: 0, // default: Infinity
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'cypress/config/settings.cypress.json',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -20,5 +20,8 @@
|
||||
"files.associations": {
|
||||
"globals.css": "tailwindcss"
|
||||
},
|
||||
"i18n-ally.localesPaths": ["src/i18n/locale"]
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n/locale"
|
||||
],
|
||||
"yaml.format.singleQuote": true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,45 @@
|
||||
|
||||
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
|
||||
|
||||
## AI Assistance Notice
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> If you are using **any kind of AI assistance** to contribute to Jellyseerr,
|
||||
> it must be disclosed in the pull request.
|
||||
|
||||
If you are using any kind of AI assistance while contributing to Jellyseerr,
|
||||
**this must be disclosed in the pull request**, along with the extent to
|
||||
which AI assistance was used (e.g. docs only vs. code generation).
|
||||
If PR responses are being generated by an AI, disclose that as well.
|
||||
As a small exception, trivial tab-completion doesn't need to be disclosed,
|
||||
so long as it is limited to single keywords or short phrases.
|
||||
|
||||
An example disclosure:
|
||||
|
||||
> This PR was written primarily by Claude Code.
|
||||
|
||||
Or a more detailed disclosure:
|
||||
|
||||
> I consulted ChatGPT to understand the codebase but the solution
|
||||
> was fully authored manually by myself.
|
||||
|
||||
Failure to disclose this is first and foremost rude to the human operators
|
||||
on the other end of the pull request, but it also makes it difficult to
|
||||
determine how much scrutiny to apply to the contribution.
|
||||
|
||||
In a perfect world, AI assistance would produce equal or higher quality
|
||||
work than any human. That isn't the world we live in today, and in most cases
|
||||
it's generating slop. I say this despite being a fan of and using them
|
||||
successfully myself (with heavy supervision)!
|
||||
|
||||
When using AI assistance, we expect contributors to understand the code
|
||||
that is produced and be able to answer critical questions about it. It
|
||||
isn't a maintainers job to review a PR so broken that it requires
|
||||
significant rework to be acceptable.
|
||||
|
||||
Please be respectful to maintainers and disclose AI assistance.
|
||||
|
||||
## Development
|
||||
|
||||
### Tools Required
|
||||
@@ -158,4 +197,4 @@ DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate ser
|
||||
|
||||
## Attribution
|
||||
|
||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides.
|
||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), [Overseerr](https://github.com/sct/Overseerr) and [Ghostty](https://github.com/ghostty-org/ghostty) contribution guides.
|
||||
|
||||
35
Dockerfile
35
Dockerfile
@@ -2,8 +2,11 @@ FROM node:22-alpine AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG TARGETPLATFORM
|
||||
ARG COMMIT_TAG
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
@@ -14,47 +17,27 @@ RUN \
|
||||
;; \
|
||||
esac
|
||||
|
||||
RUN npm install --global pnpm@9
|
||||
RUN npm install --global pnpm@10
|
||||
|
||||
COPY package.json pnpm-lock.yaml postinstall-win.js ./
|
||||
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
||||
|
||||
COPY . ./
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# remove development dependencies
|
||||
RUN pnpm prune --prod --ignore-scripts
|
||||
|
||||
RUN rm -rf src server .next/cache charts gen-docs docs
|
||||
|
||||
RUN touch config/DOCKER
|
||||
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
RUN pnpm prune --prod --ignore-scripts && \
|
||||
rm -rf src server .next/cache charts gen-docs docs && \
|
||||
touch config/DOCKER && \
|
||||
echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
# OCI Meta information
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
LABEL \
|
||||
org.opencontainers.image.authors="Fallenbagel" \
|
||||
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||
org.opencontainers.image.created=${BUILD_DATE} \
|
||||
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||
org.opencontainers.image.title="Jellyseerr" \
|
||||
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
||||
|
||||
RUN npm install -g pnpm@9
|
||||
RUN npm install -g pnpm@10
|
||||
|
||||
# copy from build image
|
||||
COPY --from=BUILD_IMAGE /app ./
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:22-alpine
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install --global pnpm@9
|
||||
RUN npm install --global pnpm@10
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-69-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-71-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -173,6 +173,10 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JamsRepos"><img src="https://avatars.githubusercontent.com/u/1347620?v=4?s=100" width="100px;" alt="Jam"/><br /><sub><b>Jam</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JamsRepos" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.joelowrance.com"><img src="https://avatars.githubusercontent.com/u/63176?v=4?s=100" width="100px;" alt="Joe Lowrance"/><br /><sub><b>Joe Lowrance</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joelowrance" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xSysR3ll"><img src="https://avatars.githubusercontent.com/u/31414959?v=4?s=100" width="100px;" alt="0xsysr3ll"/><br /><sub><b>0xsysr3ll</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=0xSysR3ll" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://sudo-kraken.github.io/docs/"><img src="https://avatars.githubusercontent.com/u/53116754?v=4?s=100" width="100px;" alt="Joe Harrison"/><br /><sub><b>Joe Harrison</b></sub></a><br /><a href="#infra-sudo-kraken" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ale183"><img src="https://avatars.githubusercontent.com/u/8809439?v=4?s=100" width="100px;" alt="ale183"/><br /><sub><b>ale183</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ale183" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,7 +3,7 @@ kubeVersion: ">=1.23.0-0"
|
||||
name: jellyseerr-chart
|
||||
description: Jellyseerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 2.6.2
|
||||
version: 2.7.0
|
||||
appVersion: "2.7.3"
|
||||
maintainers:
|
||||
- name: Jellyseerr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jellyseerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Jellyseerr helm chart for Kubernetes
|
||||
|
||||
@@ -20,6 +20,17 @@ Jellyseerr helm chart for Kubernetes
|
||||
|
||||
Kubernetes: `>=1.23.0-0`
|
||||
|
||||
## Update Notes
|
||||
|
||||
### Updating to 2.7.0
|
||||
|
||||
Jellyseerr is a stateful application and it is not designed to have multiple replicas. In version 2.7.0 we address this by:
|
||||
|
||||
- replacing `Deployment` with `StatefulSet`
|
||||
- removing `replicaCount` value
|
||||
|
||||
If `replicaCount` value was used - remove it. Helm update should work fine after that.
|
||||
|
||||
## Values
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
@@ -55,7 +66,6 @@ Kubernetes: `>=1.23.0-0`
|
||||
| probes.livenessProbe | object | `{}` | Configure liveness probe |
|
||||
| probes.readinessProbe | object | `{}` | Configure readiness probe |
|
||||
| probes.startupProbe | string | `nil` | Configure startup probe |
|
||||
| replicaCount | int | `1` | |
|
||||
| resources | object | `{}` | |
|
||||
| securityContext | object | `{}` | |
|
||||
| service.port | int | `80` | |
|
||||
@@ -64,7 +74,6 @@ Kubernetes: `>=1.23.0-0`
|
||||
| serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? |
|
||||
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
|
||||
| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template |
|
||||
| strategy | object | `{"type":"Recreate"}` | Deployment strategy |
|
||||
| tolerations | list | `[]` | |
|
||||
| volumeMounts | list | `[]` | Additional volumeMounts on the output Deployment definition. |
|
||||
| volumes | list | `[]` | Additional volumes on the output Deployment definition. |
|
||||
| volumeMounts | list | `[]` | Additional volumeMounts on the output StatefulSet definition. |
|
||||
| volumes | list | `[]` | Additional volumes on the output StatefulSet definition. |
|
||||
|
||||
@@ -14,4 +14,15 @@
|
||||
|
||||
{{ template "chart.requirementsSection" . }}
|
||||
|
||||
## Update Notes
|
||||
|
||||
### Updating to 2.7.0
|
||||
|
||||
Jellyseerr is a stateful application and it is not designed to have multiple replicas. In version 2.7.0 we address this by:
|
||||
|
||||
- replacing `Deployment` with `StatefulSet`
|
||||
- removing `replicaCount` value
|
||||
|
||||
If `replicaCount` value was used - remove it. Helm update should work fine after that.
|
||||
|
||||
{{ template "chart.valuesSection" . }}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "jellyseerr.fullname" . }}
|
||||
labels:
|
||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
strategy:
|
||||
type: {{ .Values.strategy.type }}
|
||||
serviceName: {{ include "jellyseerr.fullname" . }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "jellyseerr.selectorLabels" . | nindent 6 }}
|
||||
@@ -1,5 +1,3 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
registry: ghcr.io
|
||||
repository: fallenbagel/jellyseerr
|
||||
@@ -12,10 +10,6 @@ imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# -- Deployment strategy
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
# Liveness / Readiness / Startup Probes
|
||||
probes:
|
||||
# -- Configure liveness probe
|
||||
@@ -115,14 +109,14 @@ resources: {}
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
# -- Additional volumes on the output Deployment definition.
|
||||
# -- Additional volumes on the output StatefulSet definition.
|
||||
volumes: []
|
||||
# - name: foo
|
||||
# secret:
|
||||
# secretName: mysecret
|
||||
# optional: false
|
||||
|
||||
# -- Additional volumeMounts on the output Deployment definition.
|
||||
# -- Additional volumeMounts on the output StatefulSet definition.
|
||||
volumeMounts: []
|
||||
# - name: foo
|
||||
# mountPath: "/etc/foo"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Jellyseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
@@ -180,5 +179,26 @@
|
||||
"image-cache-cleanup": {
|
||||
"schedule": "0 0 5 * * *"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"csrfProtection": false,
|
||||
"trustProxy": false,
|
||||
"forceIpv4First": false,
|
||||
"dnsServers": "",
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"hostname": "",
|
||||
"port": 8080,
|
||||
"useSsl": false,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"bypassFilter": "",
|
||||
"bypassLocalAddresses": true
|
||||
},
|
||||
"dnsCache": {
|
||||
"enabled": false,
|
||||
"forceMinTtl": 0,
|
||||
"forceMaxTtl": -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
148
cypress/e2e/providers/tvdb.cy.ts
Normal file
148
cypress/e2e/providers/tvdb.cy.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
describe('TVDB Integration', () => {
|
||||
// Constants for routes and selectors
|
||||
const ROUTES = {
|
||||
home: '/',
|
||||
metadataSettings: '/settings/metadata',
|
||||
tomorrowIsOursTvShow: '/tv/72879',
|
||||
monsterTvShow: '/tv/225634',
|
||||
dragonnBallZKaiAnime: '/tv/61709',
|
||||
};
|
||||
|
||||
const SELECTORS = {
|
||||
sidebarToggle: '[data-testid=sidebar-toggle]',
|
||||
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
|
||||
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
|
||||
metadataTestButton: 'button[type="button"]:contains("Test")',
|
||||
metadataSaveButton: '[data-testid="metadata-save-button"]',
|
||||
tmdbStatus: '[data-testid="tmdb-status"]',
|
||||
tvdbStatus: '[data-testid="tvdb-status"]',
|
||||
tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]',
|
||||
animeMetadataProviderSelector:
|
||||
'[data-testid="anime-metadata-provider-selector"]',
|
||||
seasonSelector: '[data-testid="season-selector"]',
|
||||
season1: 'Season 1',
|
||||
season2: 'Season 2',
|
||||
season3: 'Season 3',
|
||||
episodeList: '[data-testid="episode-list"]',
|
||||
episode9: '9 - Hang Men',
|
||||
};
|
||||
|
||||
// Reusable commands
|
||||
const navigateToMetadataSettings = () => {
|
||||
cy.visit(ROUTES.home);
|
||||
cy.get(SELECTORS.sidebarToggle).click();
|
||||
cy.get(SELECTORS.sidebarSettingsMobile).click();
|
||||
cy.get(
|
||||
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]`
|
||||
).click();
|
||||
};
|
||||
|
||||
const testAndVerifyMetadataConnection = () => {
|
||||
cy.intercept('POST', '/api/v1/settings/metadatas/test').as(
|
||||
'testConnection'
|
||||
);
|
||||
cy.get(SELECTORS.metadataTestButton).click();
|
||||
return cy.wait('@testConnection');
|
||||
};
|
||||
|
||||
const saveMetadataSettings = (customBody = null) => {
|
||||
if (customBody) {
|
||||
cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => {
|
||||
req.body = customBody;
|
||||
}).as('saveMetadata');
|
||||
} else {
|
||||
// Else just intercept without modifying body
|
||||
cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata');
|
||||
}
|
||||
|
||||
cy.get(SELECTORS.metadataSaveButton).click();
|
||||
return cy.wait('@saveMetadata');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Perform login
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
|
||||
// Navigate to Metadata settings
|
||||
navigateToMetadataSettings();
|
||||
|
||||
// Verify we're on the correct settings page
|
||||
cy.contains('h3', 'Metadata Providers').should('be.visible');
|
||||
|
||||
// Configure TVDB as TV provider and test connection
|
||||
cy.get(SELECTORS.tvMetadataProviderSelector).click();
|
||||
|
||||
// get id react-select-4-option-1
|
||||
cy.get('[class*="react-select__option"]').contains('TheTVDB').click();
|
||||
|
||||
// Test the connection
|
||||
testAndVerifyMetadataConnection().then(({ response }) => {
|
||||
expect(response.statusCode).to.equal(200);
|
||||
// Check TVDB connection status
|
||||
cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational');
|
||||
});
|
||||
|
||||
// Save settings
|
||||
saveMetadataSettings({
|
||||
anime: 'tvdb',
|
||||
tv: 'tvdb',
|
||||
}).then(({ response }) => {
|
||||
expect(response.statusCode).to.equal(200);
|
||||
expect(response.body.tv).to.equal('tvdb');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => {
|
||||
// Navigate to the TV show
|
||||
cy.visit(ROUTES.tomorrowIsOursTvShow);
|
||||
|
||||
// Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple)
|
||||
// cy.get(SELECTORS.seasonSelector).should('exist');
|
||||
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||
// Select Season 2 and verify it loads
|
||||
cy.contains(SELECTORS.season2)
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
// Verify that episodes are displayed for Season 2
|
||||
cy.contains('260 - Episode 506').should('be.visible');
|
||||
});
|
||||
|
||||
it('Should display "Monster" show information correctly when not existing on TVDB', () => {
|
||||
// Navigate to the TV show
|
||||
cy.visit(ROUTES.monsterTvShow);
|
||||
|
||||
// Intercept season 1 request
|
||||
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||
|
||||
// Select Season 1
|
||||
cy.contains(SELECTORS.season1)
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
// Wait for the season data to load
|
||||
cy.wait('@season1');
|
||||
|
||||
// Verify specific episode exists
|
||||
cy.contains(SELECTORS.episode9).should('be.visible');
|
||||
});
|
||||
|
||||
it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => {
|
||||
// Navigate to the TV show
|
||||
cy.visit(ROUTES.dragonnBallZKaiAnime);
|
||||
|
||||
// Intercept season 1 request
|
||||
cy.intercept('/api/v1/tv/61709/season/1').as('season1');
|
||||
|
||||
// Select Season 2 and verify it visible
|
||||
cy.contains(SELECTORS.season2)
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
// select season 3 and verify it not visible
|
||||
cy.contains(SELECTORS.season3).should('not.exist');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import TabItem from '@theme/TabItem';
|
||||
|
||||
### Prerequisites
|
||||
- [Node.js 22.x](https://nodejs.org/en/download/)
|
||||
- [Pnpm 9.x](https://pnpm.io/installation)
|
||||
- [Pnpm 10.x](https://pnpm.io/installation)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Unix (Linux, macOS)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Kubernetes
|
||||
title: Kubernetes (Advanced)
|
||||
description: Install Jellyseerr in Kubernetes
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
21
docs/using-jellyseerr/notifications/gotify.md
Normal file
21
docs/using-jellyseerr/notifications/gotify.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Gotify
|
||||
description: Configure Gotify notifications.
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Gotify
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server URL
|
||||
|
||||
Set this to the URL of your Gotify server.
|
||||
|
||||
### Application Token
|
||||
|
||||
Add an application to your Gotify server, and set this field to the generated application token.
|
||||
|
||||
:::info
|
||||
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
|
||||
:::
|
||||
29
docs/using-jellyseerr/notifications/ntfy.md
Normal file
29
docs/using-jellyseerr/notifications/ntfy.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: ntfy.sh
|
||||
description: Configure ntfy.sh notifications.
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# ntfy.sh
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Root URL
|
||||
|
||||
Set this to the URL of your ntfy.sh server.
|
||||
|
||||
### Topic
|
||||
|
||||
Set this to the topic you want to send notifications to.
|
||||
|
||||
### Username + Password authentication (optional)
|
||||
|
||||
Set this to the username and password for your ntfy.sh server.
|
||||
|
||||
### Token authentication (optional)
|
||||
|
||||
Set this to the token for your ntfy.sh server.
|
||||
|
||||
:::info
|
||||
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
|
||||
:::
|
||||
23
docs/using-jellyseerr/notifications/pushbullet.md
Normal file
23
docs/using-jellyseerr/notifications/pushbullet.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Pushbullet
|
||||
description: Configure Pushbullet notifications.
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# Pushbullet
|
||||
|
||||
:::info
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
### Access Token
|
||||
|
||||
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Jellyseerr access to the Pushbullet API.
|
||||
|
||||
### Channel Tag (optional)
|
||||
|
||||
Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.
|
||||
27
docs/using-jellyseerr/notifications/pushover.md
Normal file
27
docs/using-jellyseerr/notifications/pushover.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Pushover
|
||||
description: Configure Pushover notifications.
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
# Pushover
|
||||
|
||||
:::info
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
### Application/API Token
|
||||
|
||||
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
|
||||
|
||||
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
|
||||
|
||||
### User Key
|
||||
|
||||
Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
|
||||
|
||||
For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).
|
||||
17
docs/using-jellyseerr/notifications/slack.md
Normal file
17
docs/using-jellyseerr/notifications/slack.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Configure Slack notifications.
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
# Slack
|
||||
|
||||
## Configuration
|
||||
|
||||
### Webhook URL
|
||||
|
||||
Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
|
||||
|
||||
:::info
|
||||
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
|
||||
:::
|
||||
39
docs/using-jellyseerr/notifications/telegram.md
Normal file
39
docs/using-jellyseerr/notifications/telegram.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Telegram
|
||||
description: Configure Telegram notifications.
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
# Telegram
|
||||
|
||||
:::info
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
:::info
|
||||
In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
|
||||
|
||||
Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
|
||||
:::
|
||||
|
||||
### Bot Username (optional)
|
||||
|
||||
If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
|
||||
|
||||
The bot username should end with `_bot`, and the `@` prefix should be omitted.
|
||||
|
||||
### Bot Authentication Token
|
||||
|
||||
At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
|
||||
|
||||
### Chat ID
|
||||
|
||||
To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
|
||||
|
||||
### Send Silently (optional)
|
||||
|
||||
Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.
|
||||
138
docs/using-jellyseerr/notifications/webhook.md
Normal file
138
docs/using-jellyseerr/notifications/webhook.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Webhook
|
||||
description: Configure webhook notifications.
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Webhook
|
||||
|
||||
The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Webhook URL
|
||||
|
||||
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||
|
||||
### Authorization Header (optional)
|
||||
|
||||
:::info
|
||||
This is typically not needed. Please refer to your webhook provider's documentation for details.
|
||||
:::
|
||||
|
||||
This value will be sent as an `Authorization` HTTP header.
|
||||
|
||||
### JSON Payload
|
||||
|
||||
Customize the JSON payload to suit your needs. Jellyseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
|
||||
|
||||
## Template Variables
|
||||
|
||||
### General
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
|
||||
| `{{event}}` | A friendly description of the notification event |
|
||||
| `{{subject}}` | The notification subject (typically the media title) |
|
||||
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
|
||||
| `{{image}}` | The notification image (typically the media poster) |
|
||||
|
||||
### Notify User
|
||||
|
||||
These variables are for the target recipient of the notification.
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------- |
|
||||
| `{{notifyuser_username}}` | The target notification recipient's username |
|
||||
| `{{notifyuser_email}}` | The target notification recipient's email address |
|
||||
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
|
||||
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
|
||||
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
|
||||
|
||||
:::info
|
||||
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
|
||||
|
||||
- Request Pending Approval
|
||||
- Request Automatically Approved
|
||||
- Request Processing Failed
|
||||
|
||||
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
|
||||
|
||||
- Request Approved
|
||||
- Request Declined
|
||||
- Request Available
|
||||
|
||||
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
|
||||
:::
|
||||
|
||||
### Special
|
||||
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
| Variable | Value |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `{{media}}` | The relevant media object |
|
||||
| `{{request}}` | The relevant request object |
|
||||
| `{{issue}}` | The relevant issue object |
|
||||
| `{{comment}}` | The relevant issue comment object |
|
||||
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
|
||||
|
||||
#### Media
|
||||
|
||||
The `{{media}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
These following special variables are only included in media-related notifications, such as requests.
|
||||
|
||||
| Variable | Value |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||
| `{{media_tmdbid}}` | The media's TMDB ID |
|
||||
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
|
||||
#### Request
|
||||
|
||||
The `{{request}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in request-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{request_id}}` | The request ID |
|
||||
| `{{requestedBy_username}}` | The requesting user's username |
|
||||
| `{{requestedBy_email}}` | The requesting user's email address |
|
||||
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Issue
|
||||
|
||||
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `{{issue_id}}` | The issue ID |
|
||||
| `{{reportedBy_username}}` | The requesting user's username |
|
||||
| `{{reportedBy_email}}` | The requesting user's email address |
|
||||
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Comment
|
||||
|
||||
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue comment-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{comment_message}}` | The comment message |
|
||||
| `{{commentedBy_username}}` | The commenting user's username |
|
||||
| `{{commentedBy_email}}` | The commenting user's email address |
|
||||
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
|
||||
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
|
||||
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
|
||||
16
docs/using-jellyseerr/settings/dns-caching.md
Normal file
16
docs/using-jellyseerr/settings/dns-caching.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: DNS Caching
|
||||
description: Configure DNS caching settings.
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# DNS Caching
|
||||
|
||||
Jellyseerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can enable the DNS caching settings in the Network tab of the Jellyseerr settings. The default values follow the standard DNS caching behavior.
|
||||
|
||||
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
|
||||
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: Jobs & Cache
|
||||
description: Configure jobs and cache settings.
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Jobs & Cache
|
||||
|
||||
24
gen-docs/blog/2025-09-29-introducing-jellyseerr-blog.md
Normal file
24
gen-docs/blog/2025-09-29-introducing-jellyseerr-blog.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Welcome to the Jellyseerr Blog
|
||||
description: The official Jellyseerr blog for release notes, technical updates, and community news.
|
||||
slug: welcome
|
||||
authors: [fallenbagel, gauthier-th]
|
||||
tags: [announcement, jellyseerr, blog]
|
||||
image: https://raw.githubusercontent.com/fallenbagel/jellyseerr/refs/heads/develop/gen-docs/static/img/logo.svg
|
||||
hide_table_of_contents: false
|
||||
---
|
||||
|
||||
We are pleased to introduce the official Jellyseerr blog.
|
||||
|
||||
This space will serve as the central place for:
|
||||
|
||||
- Release announcements
|
||||
- Updates on new features and improvements
|
||||
- Technical articles, such as details on our [**DNS caching package**](https://github.com/jellyseerr/dns-caching) and other enhancements
|
||||
- Community-related news
|
||||
|
||||
<!--truncate-->
|
||||
|
||||
Our goal is to keep the community informed and provide deeper insights into the ongoing development of Jellyseerr.
|
||||
|
||||
Thank you for being part of the Jellyseerr project. More updates will follow soon.
|
||||
21
gen-docs/blog/authors.yml
Normal file
21
gen-docs/blog/authors.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
fallenbagel:
|
||||
name: Fallenbagel
|
||||
page: true
|
||||
title: Developer & Maintainer of Jellyseerr
|
||||
description: Core Maintainer & Developer of Jellyseerr | Full-Stack Software Engineer | MSc Software Engineering Candidate.
|
||||
url: https://github.com/fallenbagel
|
||||
image_url: https://github.com/fallenbagel.png
|
||||
email: hello@fallenbagel.com
|
||||
socials:
|
||||
github: fallenbagel
|
||||
|
||||
gauthier-th:
|
||||
name: Gauthier
|
||||
page: true
|
||||
title: Co-Developer & Co-Maintainer of Jellyseerr
|
||||
description: Co-Maintainer & Developer of Jellyseerr | PhD Student in AI at ICB, Dijon
|
||||
url: https://gauthierth.fr
|
||||
image_url: https://github.com/gauthier-th.png
|
||||
email: mail@gauthierth.fr
|
||||
socials:
|
||||
github: gauthier-th
|
||||
@@ -34,7 +34,6 @@ const config: Config = {
|
||||
editUrl:
|
||||
'https://github.com/fallenbagel/jellyseerr/edit/develop/docs/',
|
||||
},
|
||||
blog: false,
|
||||
pages: false,
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
@@ -69,6 +68,11 @@ const config: Config = {
|
||||
src: 'img/logo.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
to: 'blog',
|
||||
label: 'Blog',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/fallenbagel/jellyseerr',
|
||||
label: 'GitHub',
|
||||
@@ -88,6 +92,19 @@ const config: Config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
items: [
|
||||
{
|
||||
label: 'Blog',
|
||||
to: '/blog',
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/fallenbagel/jellyseerr',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "gen-docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
@@ -15,9 +16,9 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.4.0",
|
||||
"@docusaurus/preset-classic": "3.4.0",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.44.2",
|
||||
"@docusaurus/core": "3.9.1",
|
||||
"@docusaurus/preset-classic": "3.9.1",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.52.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
@@ -26,14 +27,11 @@
|
||||
"tailwindcss": "^3.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.4.0",
|
||||
"@docusaurus/tsconfig": "3.4.0",
|
||||
"@docusaurus/types": "3.4.0",
|
||||
"@docusaurus/module-type-aliases": "3.9.1",
|
||||
"@docusaurus/tsconfig": "3.9.1",
|
||||
"@docusaurus/types": "3.9.1",
|
||||
"typescript": "~5.2.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"prismjs": "PrismJS/prism"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
|
||||
8764
gen-docs/pnpm-lock.yaml
generated
8764
gen-docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -260,9 +260,51 @@ components:
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
forceIpv4First:
|
||||
type: boolean
|
||||
example: false
|
||||
trustProxy:
|
||||
type: boolean
|
||||
example: true
|
||||
example: false
|
||||
proxy:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
hostname:
|
||||
type: string
|
||||
example: ''
|
||||
port:
|
||||
type: number
|
||||
example: 8080
|
||||
useSsl:
|
||||
type: boolean
|
||||
example: false
|
||||
user:
|
||||
type: string
|
||||
example: ''
|
||||
password:
|
||||
type: string
|
||||
example: ''
|
||||
bypassFilter:
|
||||
type: string
|
||||
example: ''
|
||||
bypassLocalAddresses:
|
||||
type: boolean
|
||||
example: true
|
||||
dnsCache:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
forceMinTtl:
|
||||
type: number
|
||||
example: 0
|
||||
forceMaxTtl:
|
||||
type: number
|
||||
example: -1
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -477,6 +519,20 @@ components:
|
||||
serverID:
|
||||
type: string
|
||||
readOnly: true
|
||||
MetadataSettings:
|
||||
type: object
|
||||
properties:
|
||||
settings:
|
||||
type: object
|
||||
properties:
|
||||
tv:
|
||||
type: string
|
||||
enum: [tvdb, tmdb]
|
||||
example: 'tvdb'
|
||||
anime:
|
||||
type: string
|
||||
enum: [tvdb, tmdb]
|
||||
example: 'tvdb'
|
||||
TautulliSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1395,6 +1451,9 @@ components:
|
||||
type: string
|
||||
jsonPayload:
|
||||
type: string
|
||||
supportVariables:
|
||||
type: boolean
|
||||
example: false
|
||||
TelegramSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2526,6 +2585,67 @@ paths:
|
||||
type: string
|
||||
thumb:
|
||||
type: string
|
||||
/settings/metadatas:
|
||||
get:
|
||||
summary: Get Metadata settings
|
||||
description: Retrieves current Metadata settings.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetadataSettings'
|
||||
put:
|
||||
summary: Update Metadata settings
|
||||
description: Updates Metadata settings with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetadataSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were successfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetadataSettings'
|
||||
/settings/metadatas/test:
|
||||
post:
|
||||
summary: Test Provider configuration
|
||||
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tmdb:
|
||||
type: boolean
|
||||
example: true
|
||||
tvdb:
|
||||
type: boolean
|
||||
example: true
|
||||
responses:
|
||||
'200':
|
||||
description: Succesfully connected to TVDB
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: 'Successfully connected to TVDB'
|
||||
/settings/tautulli:
|
||||
get:
|
||||
summary: Get Tautulli settings
|
||||
@@ -2967,6 +3087,68 @@ paths:
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
dnsCache:
|
||||
type: object
|
||||
properties:
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 1
|
||||
maxSize:
|
||||
type: number
|
||||
example: 500
|
||||
hits:
|
||||
type: number
|
||||
example: 19
|
||||
misses:
|
||||
type: number
|
||||
example: 1
|
||||
failures:
|
||||
type: number
|
||||
example: 0
|
||||
ipv4Fallbacks:
|
||||
type: number
|
||||
example: 0
|
||||
hitRate:
|
||||
type: number
|
||||
example: 0.95
|
||||
entries:
|
||||
type: array
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
addresses:
|
||||
type: object
|
||||
properties:
|
||||
ipv4:
|
||||
type: number
|
||||
example: 1
|
||||
ipv6:
|
||||
type: number
|
||||
example: 1
|
||||
activeAddress:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
family:
|
||||
type: number
|
||||
example: 4
|
||||
age:
|
||||
type: number
|
||||
example: 10
|
||||
ttl:
|
||||
type: number
|
||||
example: 10
|
||||
networkErrors:
|
||||
type: number
|
||||
example: 0
|
||||
hits:
|
||||
type: number
|
||||
example: 1
|
||||
misses:
|
||||
type: number
|
||||
example: 1
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
@@ -3006,6 +3188,21 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed cache'
|
||||
/settings/cache/dns/{dnsEntry}/flush:
|
||||
post:
|
||||
summary: Flush a specific DNS cache entry
|
||||
description: Flushes a specific DNS cache entry
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: dnsEntry
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed dns cache'
|
||||
/settings/logs:
|
||||
get:
|
||||
summary: Returns logs
|
||||
@@ -5001,6 +5198,12 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: 1,2
|
||||
- in: query
|
||||
name: excludeKeywords
|
||||
schema:
|
||||
type: string
|
||||
example: 3,4
|
||||
description: Comma-separated list of keyword IDs to exclude from results
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
@@ -5321,6 +5524,12 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: 1,2
|
||||
- in: query
|
||||
name: excludeKeywords
|
||||
schema:
|
||||
type: string
|
||||
example: 3,4
|
||||
description: Comma-separated list of keyword IDs to exclude from results
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
@@ -5944,7 +6153,7 @@ paths:
|
||||
get:
|
||||
summary: Gets request counts
|
||||
description: |
|
||||
Returns the number of pending and approved requests.
|
||||
Returns the number of requests by status including pending, approved, available, and completed requests.
|
||||
tags:
|
||||
- request
|
||||
responses:
|
||||
@@ -5971,6 +6180,8 @@ paths:
|
||||
type: number
|
||||
available:
|
||||
type: number
|
||||
completed:
|
||||
type: number
|
||||
/request/{requestId}:
|
||||
get:
|
||||
summary: Get MediaRequest
|
||||
@@ -6353,7 +6564,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TvDetails'
|
||||
/tv/{tvId}/season/{seasonId}:
|
||||
/tv/{tvId}/season/{seasonNumber}:
|
||||
get:
|
||||
summary: Get season details and episode list
|
||||
description: Returns season details with a list of episodes in a JSON object.
|
||||
@@ -6367,11 +6578,11 @@ paths:
|
||||
type: number
|
||||
example: 76479
|
||||
- in: path
|
||||
name: seasonId
|
||||
name: seasonNumber
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
example: 123456
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
|
||||
34
package.json
34
package.json
@@ -2,6 +2,7 @@
|
||||
"name": "jellyseerr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
@@ -57,6 +58,7 @@
|
||||
"cronstrue": "2.23.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dns-caching": "^0.2.7",
|
||||
"email-templates": "12.0.1",
|
||||
"email-validator": "2.0.4",
|
||||
"express": "4.21.2",
|
||||
@@ -115,11 +117,8 @@
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codedependant/semantic-release-docker": "^5.1.0",
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@semantic-release/changelog": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
@@ -169,7 +168,6 @@
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"semantic-release": "24.2.7",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
@@ -178,7 +176,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
"pnpm": "^9.0.0"
|
||||
"pnpm": "^10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
@@ -207,28 +205,12 @@
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}
|
||||
],
|
||||
"@semantic-release/npm",
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"package.json",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"message": "chore(release): ${nextRelease.version}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@codedependant/semantic-release-docker",
|
||||
{
|
||||
"dockerArgs": {
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
"COMMIT_TAG": "${GITHUB_SHA}"
|
||||
},
|
||||
"dockerLogin": false,
|
||||
"dockerProject": "fallenbagel",
|
||||
@@ -249,7 +231,7 @@
|
||||
"@codedependant/semantic-release-docker",
|
||||
{
|
||||
"dockerArgs": {
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
"COMMIT_TAG": "${GITHUB_SHA}"
|
||||
},
|
||||
"dockerLogin": false,
|
||||
"dockerRegistry": "ghcr.io",
|
||||
@@ -282,5 +264,11 @@
|
||||
"@codedependant/semantic-release-docker",
|
||||
"@semantic-release/github"
|
||||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sqlite3",
|
||||
"bcrypt"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1961
pnpm-lock.yaml
generated
1961
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ export interface AnidbItem {
|
||||
tvdbId?: number;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
tvdbSeason?: number;
|
||||
}
|
||||
|
||||
class AnimeListMapping {
|
||||
@@ -97,6 +98,7 @@ class AnimeListMapping {
|
||||
tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId,
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping
|
||||
tvdbSeason: Number(anime.$.defaulttvdbseason),
|
||||
};
|
||||
|
||||
if (tvdbId) {
|
||||
|
||||
@@ -10,7 +10,7 @@ const DEFAULT_TTL = 300;
|
||||
// 10 seconds default rolling buffer (in ms)
|
||||
const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
|
||||
interface ExternalAPIOptions {
|
||||
export interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
rateLimit?: {
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
Tmdb?: string;
|
||||
Imdb?: string;
|
||||
Tvdb?: string;
|
||||
AniDB?: string;
|
||||
};
|
||||
MediaSources?: JellyfinMediaSource[];
|
||||
Width?: number;
|
||||
|
||||
39
server/api/metadata.ts
Normal file
39
server/api/metadata.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { TvShowProvider } from '@server/api/provider';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import Tvdb from '@server/api/tvdb';
|
||||
import { getSettings, MetadataProviderType } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
export const getMetadataProvider = async (
|
||||
mediaType: 'movie' | 'tv' | 'anime'
|
||||
): Promise<TvShowProvider> => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
if (mediaType == 'movie') {
|
||||
return new TheMovieDb();
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType == 'tv' &&
|
||||
settings.metadataSettings.tv == MetadataProviderType.TVDB
|
||||
) {
|
||||
return await Tvdb.getInstance();
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType == 'anime' &&
|
||||
settings.metadataSettings.anime == MetadataProviderType.TVDB
|
||||
) {
|
||||
return await Tvdb.getInstance();
|
||||
}
|
||||
|
||||
return new TheMovieDb();
|
||||
} catch (e) {
|
||||
logger.error('Failed to get metadata provider', {
|
||||
label: 'Metadata',
|
||||
message: e.message,
|
||||
});
|
||||
return new TheMovieDb();
|
||||
}
|
||||
};
|
||||
@@ -113,7 +113,7 @@ interface MetadataResponse {
|
||||
ratingKey: string;
|
||||
type: 'movie' | 'show';
|
||||
title: string;
|
||||
Guid: {
|
||||
Guid?: {
|
||||
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
|
||||
}[];
|
||||
}[];
|
||||
@@ -277,9 +277,18 @@ class PlexTvAPI extends ExternalAPI {
|
||||
}> {
|
||||
try {
|
||||
const watchlistCache = cacheManager.getCache('plexwatchlist');
|
||||
logger.debug('Fetching watchlist from Plex.TV', {
|
||||
offset,
|
||||
size,
|
||||
label: 'Plex.TV Metadata API',
|
||||
});
|
||||
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
|
||||
this.authToken
|
||||
);
|
||||
logger.debug(`Found cached watchlist: ${!!cachedWatchlist}`, {
|
||||
cachedWatchlist,
|
||||
label: 'Plex.TV Metadata API',
|
||||
});
|
||||
|
||||
const response = await this.axios.get<WatchlistResponse>(
|
||||
'/library/sections/watchlist/all',
|
||||
@@ -296,6 +305,10 @@ class PlexTvAPI extends ExternalAPI {
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug(`Watchlist fetch returned status ${response.status}`, {
|
||||
label: 'Plex.TV Metadata API',
|
||||
});
|
||||
|
||||
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
||||
if (response.status >= 200 && response.status <= 299) {
|
||||
cachedWatchlist = {
|
||||
@@ -312,19 +325,32 @@ class PlexTvAPI extends ExternalAPI {
|
||||
const watchlistDetails = await Promise.all(
|
||||
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
||||
async (watchlistItem) => {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
let detailedResponse: MetadataResponse;
|
||||
try {
|
||||
detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) {
|
||||
logger.warn(
|
||||
`Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
|
||||
{ label: 'Plex.TV Metadata API' }
|
||||
);
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||
|
||||
const tmdbString = metadata.Guid.find((guid) =>
|
||||
const tmdbString = metadata.Guid?.find((guid) =>
|
||||
guid.id.startsWith('tmdb')
|
||||
);
|
||||
const tvdbString = metadata.Guid.find((guid) =>
|
||||
const tvdbString = metadata.Guid?.find((guid) =>
|
||||
guid.id.startsWith('tvdb')
|
||||
);
|
||||
|
||||
@@ -343,7 +369,9 @@ class PlexTvAPI extends ExternalAPI {
|
||||
)
|
||||
);
|
||||
|
||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||
const filteredList = watchlistDetails.filter(
|
||||
(detail) => detail?.tmdbId
|
||||
) as PlexWatchlistItem[];
|
||||
|
||||
return {
|
||||
offset,
|
||||
|
||||
30
server/api/provider.ts
Normal file
30
server/api/provider.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
|
||||
export interface TvShowProvider {
|
||||
getTvShow({
|
||||
tvId,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails>;
|
||||
getTvSeason({
|
||||
tvId,
|
||||
seasonNumber,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
seasonNumber: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSeasonWithEpisodes>;
|
||||
getShowByTvdbId({
|
||||
tvdbId,
|
||||
language,
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails>;
|
||||
}
|
||||
@@ -145,6 +145,7 @@ export interface IMDBRating {
|
||||
title: string;
|
||||
url: string;
|
||||
criticsScore: number;
|
||||
criticsScoreCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +188,7 @@ class IMDBRadarrProxy extends ExternalAPI {
|
||||
title: data[0].Title,
|
||||
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||
criticsScoreCount: data[0].MovieRatings.Imdb.Count,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
|
||||
@@ -198,6 +198,25 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
}
|
||||
};
|
||||
|
||||
public renameTag = async ({
|
||||
id,
|
||||
label,
|
||||
}: {
|
||||
id: number;
|
||||
label: string;
|
||||
}): Promise<Tag> => {
|
||||
try {
|
||||
const response = await this.axios.put<Tag>(`/tag/${id}`, {
|
||||
id,
|
||||
label,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[${this.apiName}] Failed to rename tag: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
async refreshMonitoredDownloads(): Promise<void> {
|
||||
await this.runCommand('RefreshMonitoredDownloads', {});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { TvShowProvider } from '@server/api/provider';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { sortBy } from 'lodash';
|
||||
@@ -85,6 +86,7 @@ interface DiscoverMovieOptions {
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
keywords?: string;
|
||||
excludeKeywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
@@ -110,6 +112,7 @@ interface DiscoverTvOptions {
|
||||
genre?: string;
|
||||
network?: number;
|
||||
keywords?: string;
|
||||
excludeKeywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
@@ -120,7 +123,7 @@ interface DiscoverTvOptions {
|
||||
certificationCountry?: string;
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
private locale: string;
|
||||
private discoverRegion?: string;
|
||||
private originalLanguage?: string;
|
||||
@@ -341,6 +344,13 @@ class TheMovieDb extends ExternalAPI {
|
||||
}
|
||||
);
|
||||
|
||||
data.episodes = data.episodes.map((episode) => {
|
||||
if (episode.still_path) {
|
||||
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
|
||||
}
|
||||
return episode;
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||
@@ -487,6 +497,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
genre,
|
||||
studio,
|
||||
keywords,
|
||||
excludeKeywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
@@ -537,6 +548,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
with_genres: genre,
|
||||
with_companies: studio,
|
||||
with_keywords: keywords,
|
||||
without_keywords: excludeKeywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
@@ -569,6 +581,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
genre,
|
||||
network,
|
||||
keywords,
|
||||
excludeKeywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
@@ -620,6 +633,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
with_keywords: keywords,
|
||||
without_keywords: excludeKeywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
|
||||
@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
|
||||
show_id: number;
|
||||
still_path: string;
|
||||
vote_average: number;
|
||||
vote_cuont: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvSeasonResult {
|
||||
|
||||
563
server/api/tvdb/index.ts
Normal file
563
server/api/tvdb/index.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { TvShowProvider } from '@server/api/provider';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbTvEpisodeResult,
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
convertTmdbLanguageToTvdbWithFallback,
|
||||
type TvdbBaseResponse,
|
||||
type TvdbEpisode,
|
||||
type TvdbLoginResponse,
|
||||
type TvdbSeasonDetails,
|
||||
type TvdbTvDetails,
|
||||
} from '@server/api/tvdb/interfaces';
|
||||
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
|
||||
interface TvdbConfig {
|
||||
baseUrl: string;
|
||||
maxRequestsPerSecond: number;
|
||||
maxRequests: number;
|
||||
cachePrefix: AvailableCacheIds;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: TvdbConfig = {
|
||||
baseUrl: 'https://api4.thetvdb.com/v4',
|
||||
maxRequestsPerSecond: 50,
|
||||
maxRequests: 20,
|
||||
cachePrefix: 'tvdb' as const,
|
||||
};
|
||||
|
||||
const enum TvdbIdStatus {
|
||||
INVALID = -1,
|
||||
}
|
||||
|
||||
type TvdbId = number;
|
||||
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
|
||||
|
||||
class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
static instance: Tvdb;
|
||||
private readonly tmdb: TheMovieDb;
|
||||
private static readonly DEFAULT_CACHE_TTL = 43200;
|
||||
private static readonly DEFAULT_LANGUAGE = 'eng';
|
||||
private token: string;
|
||||
private pin?: string;
|
||||
|
||||
constructor(pin?: string) {
|
||||
const finalConfig = { ...DEFAULT_CONFIG };
|
||||
super(
|
||||
finalConfig.baseUrl,
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
|
||||
rateLimit: {
|
||||
maxRequests: finalConfig.maxRequests,
|
||||
maxRPS: finalConfig.maxRequestsPerSecond,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.pin = pin;
|
||||
this.tmdb = new TheMovieDb();
|
||||
}
|
||||
|
||||
public static async getInstance(): Promise<Tvdb> {
|
||||
if (!this.instance) {
|
||||
this.instance = new Tvdb();
|
||||
await this.instance.login();
|
||||
}
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<void> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
await this.login();
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Url = this.token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(Buffer.from(base64, 'base64').toString());
|
||||
|
||||
if (!payload.exp) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = payload.exp - now;
|
||||
|
||||
// refresh token 1 week before expiration
|
||||
if (diff < 604800) {
|
||||
await this.login();
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError('Failed to refresh token', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async test(): Promise<void> {
|
||||
try {
|
||||
await this.login();
|
||||
} catch (error) {
|
||||
this.handleError('Login failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<TvdbLoginResponse> {
|
||||
let body: { apiKey: string; pin?: string } = {
|
||||
apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405',
|
||||
};
|
||||
|
||||
if (this.pin) {
|
||||
body = {
|
||||
...body,
|
||||
pin: this.pin,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.post<TvdbBaseResponse<TvdbLoginResponse>>(
|
||||
'/login',
|
||||
{
|
||||
...body,
|
||||
}
|
||||
);
|
||||
|
||||
this.token = response.data.token;
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getShowByTvdbId({
|
||||
tvdbId,
|
||||
language,
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const tmdbTvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: tvdbId,
|
||||
language,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||
|
||||
if (this.isValidTvdbId(validTvdbId)) {
|
||||
return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId);
|
||||
}
|
||||
|
||||
return tmdbTvShow;
|
||||
} catch (error) {
|
||||
return tmdbTvShow;
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV show details', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvShow({
|
||||
tvId,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||
|
||||
if (this.isValidTvdbId(tvdbId)) {
|
||||
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
|
||||
}
|
||||
|
||||
return tmdbTvShow;
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV show details', error);
|
||||
return tmdbTvShow;
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV show details', error);
|
||||
return this.tmdb.getTvShow({ tvId, language });
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvSeason({
|
||||
tvId,
|
||||
seasonNumber,
|
||||
language = Tvdb.DEFAULT_LANGUAGE,
|
||||
}: {
|
||||
tvId: number;
|
||||
seasonNumber: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSeasonWithEpisodes> {
|
||||
try {
|
||||
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||
|
||||
if (!this.isValidTvdbId(tvdbId)) {
|
||||
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||
}
|
||||
|
||||
return await this.getTvdbSeasonData(
|
||||
tvdbId,
|
||||
seasonNumber,
|
||||
tvId,
|
||||
language
|
||||
);
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV season details', error);
|
||||
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[TVDB] Failed to fetch TV season details: ${error.message}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichTmdbShowWithTvdbData(
|
||||
tmdbTvShow: TmdbTvDetails,
|
||||
tvdbId: ValidTvdbId
|
||||
): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||
const seasons = this.processSeasons(tvdbData);
|
||||
|
||||
if (!seasons.length) {
|
||||
return tmdbTvShow;
|
||||
}
|
||||
|
||||
return { ...tmdbTvShow, seasons };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}`
|
||||
);
|
||||
return tmdbTvShow;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvDetails> {
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbTvDetails>>(
|
||||
`/series/${tvdbId}/extended?meta=episodes&short=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
},
|
||||
Tvdb.DEFAULT_CACHE_TTL
|
||||
);
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] {
|
||||
if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seasons = tvdbData.seasons
|
||||
.filter((season) => season.type && season.type.type === 'official')
|
||||
.sort((a, b) => a.number - b.number)
|
||||
.map((season) => this.createSeasonData(season, tvdbData))
|
||||
.filter(
|
||||
(season) => season && season.season_number >= 0
|
||||
) as TmdbTvSeasonResult[];
|
||||
|
||||
return seasons;
|
||||
}
|
||||
|
||||
private createSeasonData(
|
||||
season: TvdbSeasonDetails,
|
||||
tvdbData: TvdbTvDetails
|
||||
): TmdbTvSeasonResult {
|
||||
const seasonNumber = season.number ?? -1;
|
||||
if (seasonNumber < 0) {
|
||||
return {
|
||||
id: 0,
|
||||
episode_count: 0,
|
||||
name: '',
|
||||
overview: '',
|
||||
season_number: -1,
|
||||
poster_path: '',
|
||||
air_date: '',
|
||||
};
|
||||
}
|
||||
|
||||
const episodeCount = tvdbData.episodes.filter(
|
||||
(episode) => episode.seasonNumber === season.number
|
||||
).length;
|
||||
|
||||
return {
|
||||
id: tvdbData.id,
|
||||
episode_count: episodeCount,
|
||||
name: `${season.number}`,
|
||||
overview: '',
|
||||
season_number: season.number,
|
||||
poster_path: '',
|
||||
air_date: '',
|
||||
};
|
||||
}
|
||||
|
||||
private async getTvdbSeasonData(
|
||||
tvdbId: number,
|
||||
seasonNumber: number,
|
||||
tvId: number,
|
||||
language: string = Tvdb.DEFAULT_LANGUAGE
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||
|
||||
if (!tvdbData) {
|
||||
logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
// get season id
|
||||
const season = tvdbData.seasons.find(
|
||||
(season) =>
|
||||
season.number === seasonNumber &&
|
||||
season.type.type &&
|
||||
season.type.type === 'official'
|
||||
);
|
||||
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
|
||||
language,
|
||||
Tvdb.DEFAULT_LANGUAGE
|
||||
);
|
||||
|
||||
// check if translation is available for the season
|
||||
const availableTranslation = season.nameTranslations.filter(
|
||||
(translation) =>
|
||||
translation === wantedTranslation ||
|
||||
translation === Tvdb.DEFAULT_LANGUAGE
|
||||
);
|
||||
|
||||
if (!availableTranslation) {
|
||||
return this.getSeasonWithOriginalLanguage(
|
||||
tvdbId,
|
||||
tvId,
|
||||
seasonNumber,
|
||||
season
|
||||
);
|
||||
}
|
||||
|
||||
return this.getSeasonWithTranslation(
|
||||
tvdbId,
|
||||
tvId,
|
||||
seasonNumber,
|
||||
season,
|
||||
wantedTranslation
|
||||
);
|
||||
}
|
||||
|
||||
private async getSeasonWithTranslation(
|
||||
tvdbId: number,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
season: TvdbSeasonDetails,
|
||||
language: string
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const allEpisodes = [] as TvdbEpisode[];
|
||||
let page = 0;
|
||||
// Limit to max 50 pages to avoid infinite loops.
|
||||
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
|
||||
const maxPages = 50;
|
||||
|
||||
while (page < maxPages) {
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||
`/series/${tvdbId}/episodes/default/${language}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
params: {
|
||||
page: page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp?.data?.episodes) {
|
||||
logger.warn(
|
||||
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const { episodes } = resp.data;
|
||||
|
||||
if (!episodes) {
|
||||
logger.debug(
|
||||
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
allEpisodes.push(...episodes);
|
||||
|
||||
const hasNextPage = resp.links?.next && episodes.length > 0;
|
||||
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
if (page >= maxPages) {
|
||||
logger.warn(
|
||||
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
|
||||
);
|
||||
}
|
||||
|
||||
const episodes = this.processEpisodes(
|
||||
{ ...season, episodes: allEpisodes },
|
||||
seasonNumber,
|
||||
tvId
|
||||
);
|
||||
|
||||
return {
|
||||
episodes,
|
||||
external_ids: { tvdb_id: tvdbId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: season.id,
|
||||
air_date: season.firstAired,
|
||||
season_number: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSeasonWithOriginalLanguage(
|
||||
tvdbId: number,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
season: TvdbSeasonDetails
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||
`/seasons/${season.id}/extended`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seasons = resp.data;
|
||||
|
||||
const episodes = this.processEpisodes(seasons, seasonNumber, tvId);
|
||||
|
||||
return {
|
||||
episodes,
|
||||
external_ids: { tvdb_id: tvdbId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: seasons.id,
|
||||
air_date: seasons.firstAired,
|
||||
season_number: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private processEpisodes(
|
||||
tvdbSeason: TvdbSeasonDetails,
|
||||
seasonNumber: number,
|
||||
tvId: number
|
||||
): TmdbTvEpisodeResult[] {
|
||||
if (!tvdbSeason || !tvdbSeason.episodes) {
|
||||
logger.error('No episodes found in TVDB season data');
|
||||
return [];
|
||||
}
|
||||
|
||||
return tvdbSeason.episodes
|
||||
.filter((episode) => episode.seasonNumber === seasonNumber)
|
||||
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
|
||||
}
|
||||
|
||||
private createEpisodeData(
|
||||
episode: TvdbEpisode,
|
||||
index: number,
|
||||
tvId: number
|
||||
): TmdbTvEpisodeResult {
|
||||
return {
|
||||
id: episode.id,
|
||||
air_date: episode.aired,
|
||||
episode_number: episode.number,
|
||||
name: episode.name || `Episode ${index + 1}`,
|
||||
overview: episode.overview || '',
|
||||
season_number: episode.seasonNumber,
|
||||
production_code: '',
|
||||
show_id: tvId,
|
||||
still_path:
|
||||
episode.image && !episode.image.startsWith('https://')
|
||||
? 'https://artworks.thetvdb.com' + episode.image
|
||||
: '',
|
||||
vote_average: 1,
|
||||
vote_count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
|
||||
return {
|
||||
episodes: [],
|
||||
external_ids: { tvdb_id: tvId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: 0,
|
||||
air_date: '',
|
||||
season_number: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
|
||||
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
|
||||
}
|
||||
|
||||
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
|
||||
return tvdbId !== TvdbIdStatus.INVALID;
|
||||
}
|
||||
|
||||
private handleError(context: string, error: Error): void {
|
||||
throw new Error(`[TVDB] ${context}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default Tvdb;
|
||||
216
server/api/tvdb/interfaces.ts
Normal file
216
server/api/tvdb/interfaces.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { type AvailableLocale } from '@server/types/languages';
|
||||
|
||||
export interface TvdbBaseResponse<T> {
|
||||
data: T;
|
||||
errors: string;
|
||||
links?: TvdbPagination;
|
||||
}
|
||||
|
||||
export interface TvdbPagination {
|
||||
prev?: string;
|
||||
self: string;
|
||||
next?: string;
|
||||
totalItems: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface TvdbLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface TvDetailsAliases {
|
||||
language: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TvDetailsStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
recordType: string;
|
||||
keepUpdated: boolean;
|
||||
}
|
||||
|
||||
export interface TvdbTvDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
nameTranslations: string[];
|
||||
overwiewTranslations: string[];
|
||||
aliases: TvDetailsAliases[];
|
||||
firstAired: Date;
|
||||
lastAired: Date;
|
||||
nextAired: Date | string;
|
||||
score: number;
|
||||
status: TvDetailsStatus;
|
||||
originalCountry: string;
|
||||
originalLanguage: string;
|
||||
defaultSeasonType: string;
|
||||
isOrderRandomized: boolean;
|
||||
lastUpdated: Date;
|
||||
averageRuntime: number;
|
||||
seasons: TvdbSeasonDetails[];
|
||||
episodes: TvdbEpisode[];
|
||||
}
|
||||
|
||||
interface TvdbCompanyType {
|
||||
companyTypeId: number;
|
||||
companyTypeName: string;
|
||||
}
|
||||
|
||||
interface TvdbParentCompany {
|
||||
id?: number;
|
||||
name?: string;
|
||||
relation?: {
|
||||
id?: number;
|
||||
typeName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TvdbCompany {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
nameTranslations?: string[];
|
||||
overviewTranslations?: string[];
|
||||
aliases?: string[];
|
||||
country: string;
|
||||
primaryCompanyType: number;
|
||||
activeDate: string;
|
||||
inactiveDate?: string;
|
||||
companyType: TvdbCompanyType;
|
||||
parentCompany: TvdbParentCompany;
|
||||
tagOptions?: string[];
|
||||
}
|
||||
|
||||
interface TvdbType {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
alternateName?: string;
|
||||
}
|
||||
|
||||
interface TvdbArtwork {
|
||||
id: number;
|
||||
image: string;
|
||||
thumbnail: string;
|
||||
language: string;
|
||||
type: number;
|
||||
score: number;
|
||||
width: number;
|
||||
height: number;
|
||||
includesText: boolean;
|
||||
}
|
||||
|
||||
export interface TvdbEpisode {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
name: string;
|
||||
aired: string;
|
||||
runtime: number;
|
||||
nameTranslations: string[];
|
||||
overview?: string;
|
||||
overviewTranslations: string[];
|
||||
image: string;
|
||||
imageType: number;
|
||||
isMovie: number;
|
||||
seasons?: string[];
|
||||
number: number;
|
||||
absoluteNumber: number;
|
||||
seasonNumber: number;
|
||||
lastUpdated: string;
|
||||
finaleType?: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export interface TvdbSeasonDetails {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
type: TvdbType;
|
||||
number: number;
|
||||
nameTranslations: string[];
|
||||
overviewTranslations: string[];
|
||||
image: string;
|
||||
imageType: number;
|
||||
companies: {
|
||||
studio: TvdbCompany[];
|
||||
network: TvdbCompany[];
|
||||
production: TvdbCompany[];
|
||||
distributor: TvdbCompany[];
|
||||
special_effects: TvdbCompany[];
|
||||
};
|
||||
lastUpdated: string;
|
||||
year: string;
|
||||
episodes: TvdbEpisode[];
|
||||
trailers: string[];
|
||||
artwork: TvdbArtwork[];
|
||||
tagOptions?: string[];
|
||||
firstAired: string;
|
||||
}
|
||||
|
||||
export interface TvdbEpisodeTranslation {
|
||||
name: string;
|
||||
overview: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
|
||||
[key in AvailableLocale]: string;
|
||||
} = {
|
||||
ar: 'ara', // Arabic
|
||||
bg: 'bul', // Bulgarian
|
||||
ca: 'cat', // Catalan
|
||||
cs: 'ces', // Czech
|
||||
da: 'dan', // Danish
|
||||
de: 'deu', // German
|
||||
el: 'ell', // Greek
|
||||
en: 'eng', // English
|
||||
es: 'spa', // Spanish
|
||||
fi: 'fin', // Finnish
|
||||
fr: 'fra', // French
|
||||
he: 'heb', // Hebrew
|
||||
hi: 'hin', // Hindi
|
||||
hr: 'hrv', // Croatian
|
||||
hu: 'hun', // Hungarian
|
||||
it: 'ita', // Italian
|
||||
ja: 'jpn', // Japanese
|
||||
ko: 'kor', // Korean
|
||||
lt: 'lit', // Lithuanian
|
||||
nl: 'nld', // Dutch
|
||||
pl: 'pol', // Polish
|
||||
ro: 'ron', // Romanian
|
||||
ru: 'rus', // Russian
|
||||
sq: 'sqi', // Albanian
|
||||
sr: 'srp', // Serbian
|
||||
sv: 'swe', // Swedish
|
||||
tr: 'tur', // Turkish
|
||||
uk: 'ukr', // Ukrainian
|
||||
|
||||
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
|
||||
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
|
||||
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
|
||||
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
|
||||
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
|
||||
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
|
||||
};
|
||||
|
||||
export function convertTMDBToTVDB(tmdbCode: string): string | null {
|
||||
const normalizedCode = tmdbCode.toLowerCase();
|
||||
|
||||
return (
|
||||
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
|
||||
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function convertTmdbLanguageToTvdbWithFallback(
|
||||
tmdbCode: string,
|
||||
fallback: string
|
||||
): string {
|
||||
// First try exact match
|
||||
const tvdbCode = convertTMDBToTVDB(tmdbCode);
|
||||
if (tvdbCode) return tvdbCode;
|
||||
|
||||
return tvdbCode || fallback || 'eng'; // Default to English if no match found
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import imageproxy from '@server/routes/imageproxy';
|
||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
import { initializeDnsCache } from '@server/utils/dnsCache';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import axios from 'axios';
|
||||
@@ -80,6 +81,14 @@ app
|
||||
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
|
||||
}
|
||||
|
||||
// Add DNS caching
|
||||
if (settings.network.dnsCache?.enabled) {
|
||||
initializeDnsCache({
|
||||
forceMinTtl: settings.network.dnsCache.forceMinTtl,
|
||||
forceMaxTtl: settings.network.dnsCache.forceMaxTtl,
|
||||
});
|
||||
}
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DnsEntries, DnsStats } from 'dns-caching';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export type LogMessage = {
|
||||
@@ -64,6 +65,10 @@ export interface CacheItem {
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
dnsCache: {
|
||||
stats: DnsStats | undefined;
|
||||
entries: DnsEntries | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
|
||||
@@ -9,7 +9,8 @@ export type AvailableCacheIds =
|
||||
| 'github'
|
||||
| 'plexguid'
|
||||
| 'plextv'
|
||||
| 'plexwatchlist';
|
||||
| 'plexwatchlist'
|
||||
| 'tvdb';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -70,6 +71,10 @@ class CacheManager {
|
||||
checkPeriod: 60,
|
||||
}),
|
||||
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
||||
tvdb: new Cache('tvdb', 'The TVDB API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
};
|
||||
|
||||
public getCache(id: AvailableCacheIds): Cache {
|
||||
|
||||
@@ -109,7 +109,9 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): DiscordRichEmbed {
|
||||
const { applicationUrl } = getSettings().main;
|
||||
const settings = getSettings();
|
||||
const { applicationUrl } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.discord;
|
||||
|
||||
const appUrl =
|
||||
applicationUrl || `http://localhost:${process.env.port || 5055}`;
|
||||
@@ -223,9 +225,11 @@ class DiscordAgent
|
||||
}
|
||||
: undefined,
|
||||
fields,
|
||||
thumbnail: {
|
||||
url: payload.image,
|
||||
},
|
||||
thumbnail: embedPoster
|
||||
? {
|
||||
url: payload.image,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ class EmailAgent
|
||||
recipientEmail: string,
|
||||
recipientName?: string
|
||||
): EmailOptions | undefined {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.email;
|
||||
|
||||
if (type === Notification.TEST_NOTIFICATION) {
|
||||
return {
|
||||
@@ -129,7 +131,7 @@ class EmailAgent
|
||||
body,
|
||||
mediaName: payload.subject,
|
||||
mediaExtra: payload.extra ?? [],
|
||||
imageUrl: payload.image,
|
||||
imageUrl: embedPoster ? payload.image : undefined,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.request.requestedBy.displayName,
|
||||
actionUrl: applicationUrl
|
||||
@@ -176,7 +178,7 @@ class EmailAgent
|
||||
issueComment: payload.comment?.message,
|
||||
mediaName: payload.subject,
|
||||
extra: payload.extra ?? [],
|
||||
imageUrl: payload.image,
|
||||
imageUrl: embedPoster ? payload.image : undefined,
|
||||
timestamp: new Date().toTimeString(),
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
|
||||
@@ -22,7 +22,9 @@ class NtfyAgent
|
||||
}
|
||||
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
const { applicationUrl } = getSettings().main;
|
||||
const settings = getSettings();
|
||||
const { applicationUrl } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.ntfy;
|
||||
|
||||
const topic = this.getSettings().options.topic;
|
||||
const priority = 3;
|
||||
@@ -72,7 +74,7 @@ class NtfyAgent
|
||||
message += `\n\n**${extra.name}**\n${extra.value}`;
|
||||
}
|
||||
|
||||
const attach = payload.image;
|
||||
const attach = embedPoster ? payload.image : undefined;
|
||||
|
||||
let click;
|
||||
if (applicationUrl && payload.media) {
|
||||
|
||||
@@ -78,7 +78,9 @@ class PushoverAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<Partial<PushoverPayload>> {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.pushover;
|
||||
|
||||
const title = payload.event ?? payload.subject;
|
||||
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
||||
@@ -155,7 +157,7 @@ class PushoverAgent
|
||||
|
||||
let attachment_base64;
|
||||
let attachment_type;
|
||||
if (payload.image) {
|
||||
if (embedPoster && payload.image) {
|
||||
const imagePayload = await this.getImagePayload(payload.image);
|
||||
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
|
||||
attachment_base64 = imagePayload.attachment_base64;
|
||||
|
||||
@@ -63,7 +63,9 @@ class SlackAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.slack;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
@@ -159,13 +161,14 @@ class SlackAgent
|
||||
type: 'mrkdwn',
|
||||
text: payload.message,
|
||||
},
|
||||
accessory: payload.image
|
||||
? {
|
||||
type: 'image',
|
||||
image_url: payload.image,
|
||||
alt_text: payload.subject,
|
||||
}
|
||||
: undefined,
|
||||
accessory:
|
||||
embedPoster && payload.image
|
||||
? {
|
||||
type: 'image',
|
||||
image_url: payload.image,
|
||||
alt_text: payload.subject,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ class TelegramAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.telegram;
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
let message = `\*${this.escapeText(
|
||||
@@ -142,7 +144,7 @@ class TelegramAgent
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
return payload.image
|
||||
return embedPoster && payload.image
|
||||
? {
|
||||
photo: payload.image,
|
||||
caption: message,
|
||||
@@ -160,7 +162,7 @@ class TelegramAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
||||
payload.image ? 'sendPhoto' : 'sendMessage'
|
||||
settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage'
|
||||
}`;
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
|
||||
@@ -177,9 +177,27 @@ class WebhookAgent
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
let webhookUrl = settings.options.webhookUrl;
|
||||
|
||||
if (settings.options.supportVariables) {
|
||||
Object.keys(KeyMap).forEach((keymapKey) => {
|
||||
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
|
||||
const variableValue =
|
||||
type === Notification.TEST_NOTIFICATION
|
||||
? 'test'
|
||||
: typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) || 'test';
|
||||
webhookUrl = webhookUrl.replace(
|
||||
new RegExp(`{{${keymapKey}}}`, 'g'),
|
||||
encodeURIComponent(variableValue)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
settings.options.webhookUrl,
|
||||
webhookUrl,
|
||||
this.buildPayload(type, payload),
|
||||
settings.options.authHeader
|
||||
? {
|
||||
|
||||
@@ -42,6 +42,8 @@ class WebPushAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): PushNotificationPayload {
|
||||
const { embedPoster } = getSettings().notifications.agents.webpush;
|
||||
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
@@ -128,7 +130,7 @@ class WebPushAgent
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message,
|
||||
image: payload.image,
|
||||
image: embedPoster ? payload.image : undefined,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl,
|
||||
actionUrlTitle,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
@@ -40,9 +46,11 @@ class JellyfinScanner {
|
||||
private enable4kMovie = false;
|
||||
private enable4kShow = false;
|
||||
private asyncLock = new AsyncLock();
|
||||
private processedAnidbSeason: Map<number, Map<number, number>>;
|
||||
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
@@ -60,7 +68,7 @@ class JellyfinScanner {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
const newMedia = new Media();
|
||||
|
||||
if (!metadata?.Id) {
|
||||
@@ -71,8 +79,18 @@ class JellyfinScanner {
|
||||
return;
|
||||
}
|
||||
|
||||
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
|
||||
|
||||
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
||||
newMedia.imdbId = metadata.ProviderIds.Imdb;
|
||||
|
||||
// We use anidb only if we have the anidbId and nothing else
|
||||
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) {
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
newMedia.tmdbId = Number(result?.tmdbId ?? null);
|
||||
newMedia.imdbId = result?.imdbId;
|
||||
}
|
||||
|
||||
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
|
||||
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
||||
imdbId: newMedia.imdbId,
|
||||
@@ -83,6 +101,40 @@ class JellyfinScanner {
|
||||
throw new Error('Unable to find TMDb ID');
|
||||
}
|
||||
|
||||
// With AniDB we can have mixed libraries with movies in a "show" library
|
||||
// We take the first episode of the first season (the movie) and use it to
|
||||
// get more information, like the MediaSource
|
||||
if (anidbId && metadata.Type === 'Series') {
|
||||
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
|
||||
(md) => {
|
||||
return md.IndexNumber === 1;
|
||||
}
|
||||
);
|
||||
if (!season) {
|
||||
this.log('No season found for anidb movie', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
jellyfinitem.Id,
|
||||
season.Id
|
||||
);
|
||||
if (!episodes[0]) {
|
||||
this.log('No episode found for anidb movie', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
return;
|
||||
}
|
||||
metadata = await this.jfClient.getItemData(episodes[0].Id);
|
||||
if (!metadata) {
|
||||
this.log('No metadata found for anidb movie', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.filter(
|
||||
(MediaStream) => MediaStream.Type === 'Video'
|
||||
@@ -100,6 +152,12 @@ class JellyfinScanner {
|
||||
});
|
||||
|
||||
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
|
||||
if (!metadata) {
|
||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
||||
// outer scope back to null before this async gets called
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
@@ -192,6 +250,42 @@ class JellyfinScanner {
|
||||
}
|
||||
}
|
||||
|
||||
private async getTvShow({
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
}: {
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
let tvShow;
|
||||
|
||||
if (tmdbId) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
} else if (tvdbId) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(tvdbId),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No ID provided');
|
||||
}
|
||||
|
||||
const metadataProvider = tvShow.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
|
||||
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||
tvShow = await metadataProvider.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
}
|
||||
|
||||
return tvShow;
|
||||
}
|
||||
|
||||
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
@@ -212,8 +306,8 @@ class JellyfinScanner {
|
||||
|
||||
if (metadata.ProviderIds.Tmdb) {
|
||||
try {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: Number(metadata.ProviderIds.Tmdb),
|
||||
tvShow = await this.getTvShow({
|
||||
tmdbId: Number(metadata.ProviderIds.Tmdb),
|
||||
});
|
||||
} catch {
|
||||
this.log('Unable to find TMDb ID for this title.', 'debug', {
|
||||
@@ -223,7 +317,7 @@ class JellyfinScanner {
|
||||
}
|
||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
||||
try {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvShow = await this.getTvShow({
|
||||
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
||||
});
|
||||
} catch {
|
||||
@@ -232,6 +326,28 @@ class JellyfinScanner {
|
||||
});
|
||||
}
|
||||
}
|
||||
let tvdbSeasonFromAnidb: number | undefined;
|
||||
if (!tvShow && metadata.ProviderIds.AniDB) {
|
||||
const anidbId = Number(metadata.ProviderIds.AniDB);
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
tvdbSeasonFromAnidb = result?.tvdbSeason;
|
||||
if (result?.tvdbId) {
|
||||
try {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: result.tvdbId,
|
||||
});
|
||||
} catch {
|
||||
this.log('Unable to find AniDB ID for this title.', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
}
|
||||
}
|
||||
// With AniDB we can have mixed libraries with movies in a "show" library
|
||||
else if (result?.imdbId || result?.tmdbId) {
|
||||
await this.processMovie(jellyfinitem);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShow) {
|
||||
await this.asyncLock.dispatch(tvShow.id, async () => {
|
||||
@@ -260,9 +376,20 @@ class JellyfinScanner {
|
||||
|
||||
for (const season of seasons) {
|
||||
const JellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
const matchedJellyfinSeason = JellyfinSeasons.find(
|
||||
(md) => Number(md.IndexNumber) === season.season_number
|
||||
);
|
||||
const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
// In AniDB we don't have the concept of seasons,
|
||||
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
|
||||
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
|
||||
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
|
||||
return (
|
||||
tvdbSeasonFromAnidb === season.season_number &&
|
||||
md.IndexNumber === 1
|
||||
);
|
||||
} else {
|
||||
return Number(md.IndexNumber) === season.season_number;
|
||||
}
|
||||
});
|
||||
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.season_number
|
||||
@@ -315,6 +442,29 @@ class JellyfinScanner {
|
||||
}
|
||||
}
|
||||
|
||||
// With AniDB we can have multiple shows for one season, so we need to save
|
||||
// the episode from all the jellyfin entries to get the total
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
if (this.processedAnidbSeason.has(tvShow.id)) {
|
||||
const show = this.processedAnidbSeason.get(tvShow.id)!;
|
||||
if (show.has(season.season_number)) {
|
||||
show.set(
|
||||
season.season_number,
|
||||
show.get(season.season_number)! + totalStandard
|
||||
);
|
||||
|
||||
totalStandard = show.get(season.season_number)!;
|
||||
} else {
|
||||
show.set(season.season_number, totalStandard);
|
||||
}
|
||||
} else {
|
||||
this.processedAnidbSeason.set(
|
||||
tvShow.id,
|
||||
new Map([[season.season_number, totalStandard]])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
|
||||
@@ -527,6 +677,7 @@ class JellyfinScanner {
|
||||
}
|
||||
|
||||
private async processItems(slicedItems: JellyfinLibraryItem[]) {
|
||||
this.processedAnidbSeason = new Map();
|
||||
await Promise.all(
|
||||
slicedItems.map(async (item) => {
|
||||
if (item.Type === 'Movie') {
|
||||
@@ -624,6 +775,8 @@ class JellyfinScanner {
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
await animeList.sync();
|
||||
|
||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4kMovie) {
|
||||
this.log(
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
@@ -249,6 +255,42 @@ class PlexScanner
|
||||
});
|
||||
}
|
||||
|
||||
private async getTvShow({
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
}: {
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
let tvShow;
|
||||
|
||||
if (tmdbId) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
} else if (tvdbId) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(tvdbId),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No ID provided');
|
||||
}
|
||||
|
||||
const metadataProvider = tvShow.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
|
||||
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||
tvShow = await metadataProvider.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
}
|
||||
|
||||
return tvShow;
|
||||
}
|
||||
|
||||
private async processPlexShow(plexitem: PlexLibraryItem) {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
@@ -273,7 +315,9 @@ class PlexScanner
|
||||
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
|
||||
}
|
||||
|
||||
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
|
||||
const tvShow = await this.getTvShow({
|
||||
tmdbId: mediaIds.tmdbId,
|
||||
});
|
||||
|
||||
const seasons = tvShow.seasons;
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
|
||||
@@ -100,6 +100,16 @@ interface Quota {
|
||||
quotaDays?: number;
|
||||
}
|
||||
|
||||
export enum MetadataProviderType {
|
||||
TMDB = 'tmdb',
|
||||
TVDB = 'tvdb',
|
||||
}
|
||||
|
||||
export interface MetadataSettings {
|
||||
tv: MetadataProviderType;
|
||||
anime: MetadataProviderType;
|
||||
}
|
||||
|
||||
export interface ProxySettings {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
@@ -138,11 +148,29 @@ export interface MainSettings {
|
||||
youtubeUrl: string;
|
||||
}
|
||||
|
||||
export interface ProxySettings {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
useSsl: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
bypassFilter: string;
|
||||
bypassLocalAddresses: boolean;
|
||||
}
|
||||
|
||||
export interface DnsCacheSettings {
|
||||
enabled: boolean;
|
||||
forceMinTtl?: number;
|
||||
forceMaxTtl?: number;
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
csrfProtection: boolean;
|
||||
forceIpv4First: boolean;
|
||||
trustProxy: boolean;
|
||||
proxy: ProxySettings;
|
||||
dnsCache: DnsCacheSettings;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -179,6 +207,7 @@ interface FullPublicSettings extends PublicSettings {
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
enabled: boolean;
|
||||
embedPoster: boolean;
|
||||
types?: number;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
@@ -246,6 +275,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
|
||||
webhookUrl: string;
|
||||
jsonPayload: string;
|
||||
authHeader?: string;
|
||||
supportVariables?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,6 +362,8 @@ export interface AllSettings {
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
network: NetworkSettings;
|
||||
metadataSettings: MetadataSettings;
|
||||
migrations: string[];
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||
@@ -392,6 +424,10 @@ class Settings {
|
||||
apiKey: '',
|
||||
},
|
||||
tautulli: {},
|
||||
metadataSettings: {
|
||||
tv: MetadataProviderType.TMDB,
|
||||
anime: MetadataProviderType.TMDB,
|
||||
},
|
||||
radarr: [],
|
||||
sonarr: [],
|
||||
public: {
|
||||
@@ -401,6 +437,7 @@ class Settings {
|
||||
agents: {
|
||||
email: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
options: {
|
||||
userEmailRequired: false,
|
||||
emailFrom: '',
|
||||
@@ -415,6 +452,7 @@ class Settings {
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
@@ -424,6 +462,7 @@ class Settings {
|
||||
},
|
||||
slack: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
@@ -431,6 +470,7 @@ class Settings {
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
botAPI: '',
|
||||
@@ -441,6 +481,7 @@ class Settings {
|
||||
},
|
||||
pushbullet: {
|
||||
enabled: false,
|
||||
embedPoster: false,
|
||||
types: 0,
|
||||
options: {
|
||||
accessToken: '',
|
||||
@@ -448,6 +489,7 @@ class Settings {
|
||||
},
|
||||
pushover: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
accessToken: '',
|
||||
@@ -457,6 +499,7 @@ class Settings {
|
||||
},
|
||||
webhook: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
@@ -466,10 +509,12 @@ class Settings {
|
||||
},
|
||||
webpush: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
options: {},
|
||||
},
|
||||
gotify: {
|
||||
enabled: false,
|
||||
embedPoster: false,
|
||||
types: 0,
|
||||
options: {
|
||||
url: '',
|
||||
@@ -479,6 +524,7 @@ class Settings {
|
||||
},
|
||||
ntfy: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
url: '',
|
||||
@@ -542,7 +588,13 @@ class Settings {
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
dnsCache: {
|
||||
enabled: false,
|
||||
forceMinTtl: 0,
|
||||
forceMaxTtl: -1,
|
||||
},
|
||||
},
|
||||
migrations: [],
|
||||
};
|
||||
if (initialSettings) {
|
||||
this.data = merge(this.data, initialSettings);
|
||||
@@ -581,6 +633,14 @@ class Settings {
|
||||
this.data.tautulli = data;
|
||||
}
|
||||
|
||||
get metadataSettings(): MetadataSettings {
|
||||
return this.data.metadataSettings;
|
||||
}
|
||||
|
||||
set metadataSettings(data: MetadataSettings) {
|
||||
this.data.metadataSettings = data;
|
||||
}
|
||||
|
||||
get radarr(): RadarrSettings[] {
|
||||
return this.data.radarr;
|
||||
}
|
||||
@@ -664,6 +724,14 @@ class Settings {
|
||||
this.data.network = data;
|
||||
}
|
||||
|
||||
get migrations(): string[] {
|
||||
return this.data.migrations;
|
||||
}
|
||||
|
||||
set migrations(data: string[]) {
|
||||
this.data.migrations = data;
|
||||
}
|
||||
|
||||
get clientId(): string {
|
||||
return this.data.clientId;
|
||||
}
|
||||
|
||||
93
server/lib/settings/migrations/0007_migrate_arr_tags.ts
Normal file
93
server/lib/settings/migrations/0007_migrate_arr_tags.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
if (
|
||||
Array.isArray(settings.migrations) &&
|
||||
settings.migrations.includes('0007_migrate_arr_tags')
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find({
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
for (const radarrSettings of settings.radarr || []) {
|
||||
if (!radarrSettings.tagRequests) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
||||
});
|
||||
const radarrTags = await radarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await radarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Unable to rename Radarr tags to the new format. Please check your Radarr connection settings for the instance "${radarrSettings.name}".`,
|
||||
error.message
|
||||
);
|
||||
errorOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const sonarrSettings of settings.sonarr || []) {
|
||||
if (!sonarrSettings.tagRequests) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
const sonarrTags = await sonarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await sonarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Unable to rename Sonarr tags to the new format. Please check your Sonarr connection settings for the instance "${sonarrSettings.name}".`,
|
||||
error.message
|
||||
);
|
||||
errorOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorOccurred) {
|
||||
if (!Array.isArray(settings.migrations)) {
|
||||
settings.migrations = [];
|
||||
}
|
||||
settings.migrations.push('0007_migrate_arr_tags');
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
export default migrationArrTags;
|
||||
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
||||
seasonNumber: episode.season_number,
|
||||
showId: episode.show_id,
|
||||
voteAverage: episode.vote_average,
|
||||
voteCount: episode.vote_cuont,
|
||||
voteCount: episode.vote_count,
|
||||
stillPath: episode.still_path,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const QueryFilterOptions = z.object({
|
||||
studio: z.coerce.string().optional(),
|
||||
genre: z.coerce.string().optional(),
|
||||
keywords: z.coerce.string().optional(),
|
||||
excludeKeywords: z.coerce.string().optional(),
|
||||
language: z.coerce.string().optional(),
|
||||
withRuntimeGte: z.coerce.string().optional(),
|
||||
withRuntimeLte: z.coerce.string().optional(),
|
||||
@@ -90,6 +91,7 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
try {
|
||||
const query = ApiQuerySchema.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const excludeKeywords = query.excludeKeywords;
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(query.page),
|
||||
@@ -105,6 +107,7 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
keywords,
|
||||
excludeKeywords,
|
||||
withRuntimeGte: query.withRuntimeGte,
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
@@ -381,6 +384,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
try {
|
||||
const query = ApiQuerySchema.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const excludeKeywords = query.excludeKeywords;
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
@@ -395,6 +399,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
: undefined,
|
||||
originalLanguage: query.language,
|
||||
keywords,
|
||||
excludeKeywords,
|
||||
withRuntimeGte: query.withRuntimeGte,
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
|
||||
@@ -54,6 +54,7 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
|
||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('issue.media', 'media')
|
||||
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('issue.comments', 'comments')
|
||||
.where('issue.status IN (:...issueStatus)', {
|
||||
issueStatus: statusFilter,
|
||||
});
|
||||
|
||||
@@ -381,6 +381,12 @@ requestRoutes.get('/count', async (_req, res, next) => {
|
||||
)
|
||||
.getCount();
|
||||
|
||||
const completedCount = await query
|
||||
.where('request.status = :requestStatus', {
|
||||
requestStatus: MediaRequestStatus.COMPLETED,
|
||||
})
|
||||
.getCount();
|
||||
|
||||
return res.status(200).json({
|
||||
total: totalCount,
|
||||
movie: movieCount,
|
||||
@@ -390,6 +396,7 @@ requestRoutes.get('/count', async (_req, res, next) => {
|
||||
declined: declinedCount,
|
||||
processing: processingCount,
|
||||
available: availableCount,
|
||||
completed: completedCount,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong retrieving request counts', {
|
||||
|
||||
@@ -28,7 +28,9 @@ import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { appDataPath } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { dnsCache } from '@server/utils/dnsCache';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import type { DnsEntries, DnsStats } from 'dns-caching';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import fs from 'fs';
|
||||
@@ -37,6 +39,7 @@ import { rescheduleJob } from 'node-schedule';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import { URL } from 'url';
|
||||
import metadataRoutes from './metadata';
|
||||
import notificationRoutes from './notifications';
|
||||
import radarrRoutes from './radarr';
|
||||
import sonarrRoutes from './sonarr';
|
||||
@@ -47,6 +50,7 @@ settingsRoutes.use('/notifications', notificationRoutes);
|
||||
settingsRoutes.use('/radarr', radarrRoutes);
|
||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||
settingsRoutes.use('/metadatas', metadataRoutes);
|
||||
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
@@ -755,12 +759,19 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
|
||||
const stats: DnsStats | undefined = dnsCache?.getStats();
|
||||
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
},
|
||||
dnsCache: {
|
||||
stats,
|
||||
entries,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -778,6 +789,20 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post<{ dnsEntry: string }>(
|
||||
'/cache/dns/:dnsEntry/flush',
|
||||
(req, res, next) => {
|
||||
const dnsEntry = req.params.dnsEntry;
|
||||
|
||||
if (dnsCache) {
|
||||
dnsCache.clear(dnsEntry);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
next({ status: 404, message: 'Cache not found.' });
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post(
|
||||
'/initialize',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
|
||||
153
server/routes/settings/metadata.ts
Normal file
153
server/routes/settings/metadata.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import Tvdb from '@server/api/tvdb';
|
||||
import {
|
||||
getSettings,
|
||||
MetadataProviderType,
|
||||
type MetadataSettings,
|
||||
} from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
function getTestResultString(testValue: number): string {
|
||||
if (testValue === -1) return 'not tested';
|
||||
if (testValue === 0) return 'failed';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
const metadataRoutes = Router();
|
||||
|
||||
metadataRoutes.get('/', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
res.status(200).json({
|
||||
tv: settings.metadataSettings.tv,
|
||||
anime: settings.metadataSettings.anime,
|
||||
});
|
||||
});
|
||||
|
||||
metadataRoutes.put('/', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const body = req.body as MetadataSettings;
|
||||
|
||||
let tvdbTest = -1;
|
||||
let tmdbTest = -1;
|
||||
|
||||
try {
|
||||
if (
|
||||
body.tv === MetadataProviderType.TVDB ||
|
||||
body.anime === MetadataProviderType.TVDB
|
||||
) {
|
||||
tvdbTest = 0;
|
||||
const tvdb = await Tvdb.getInstance();
|
||||
await tvdb.test();
|
||||
tvdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'Metadata',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
body.tv === MetadataProviderType.TMDB ||
|
||||
body.anime === MetadataProviderType.TMDB
|
||||
) {
|
||||
tmdbTest = 0;
|
||||
const tmdb = new TheMovieDb();
|
||||
await tmdb.getTvShow({ tvId: 1054 });
|
||||
tmdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'MetadataProvider',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
// If a test failed, return the test results
|
||||
if (tvdbTest === 0 || tmdbTest === 0) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
tests: {
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
settings.metadataSettings = {
|
||||
tv: body.tv,
|
||||
anime: body.anime,
|
||||
};
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
tv: body.tv,
|
||||
anime: body.anime,
|
||||
tests: {
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
metadataRoutes.post('/test', async (req, res) => {
|
||||
let tvdbTest = -1;
|
||||
let tmdbTest = -1;
|
||||
|
||||
try {
|
||||
const body = req.body as { tmdb: boolean; tvdb: boolean };
|
||||
|
||||
try {
|
||||
if (body.tmdb) {
|
||||
tmdbTest = 0;
|
||||
const tmdb = new TheMovieDb();
|
||||
await tmdb.getTvShow({ tvId: 1054 });
|
||||
tmdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'MetadataProvider',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (body.tvdb) {
|
||||
tvdbTest = 0;
|
||||
const tvdb = await Tvdb.getInstance();
|
||||
await tvdb.test();
|
||||
tvdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'MetadataProvider',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
const success = !(tvdbTest === 0 || tmdbTest === 0);
|
||||
const statusCode = success ? 200 : 500;
|
||||
|
||||
return res.status(statusCode).json({
|
||||
success: success,
|
||||
tests: {
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
tests: {
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
},
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default metadataRoutes;
|
||||
@@ -270,6 +270,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
||||
|
||||
const response: typeof webhookSettings = {
|
||||
enabled: webhookSettings.enabled,
|
||||
embedPoster: webhookSettings.embedPoster,
|
||||
types: webhookSettings.types,
|
||||
options: {
|
||||
...webhookSettings.options,
|
||||
@@ -278,6 +279,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
||||
'utf8'
|
||||
)
|
||||
),
|
||||
supportVariables: webhookSettings.options.supportVariables ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -291,6 +293,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
|
||||
|
||||
settings.notifications.agents.webhook = {
|
||||
enabled: req.body.enabled,
|
||||
embedPoster: req.body.embedPoster,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
@@ -298,6 +301,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
supportVariables: req.body.options.supportVariables ?? false,
|
||||
},
|
||||
};
|
||||
await settings.save();
|
||||
@@ -321,6 +325,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
|
||||
const testBody = {
|
||||
enabled: req.body.enabled,
|
||||
embedPoster: req.body.embedPoster,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
@@ -328,6 +333,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
supportVariables: req.body.options.supportVariables ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -13,12 +16,20 @@ const tvRoutes = Router();
|
||||
|
||||
tvRoutes.get('/:id', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({
|
||||
const tmdbTv = await tmdb.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
});
|
||||
const metadataProvider = tmdbTv.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
const tv = await metadataProvider.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
});
|
||||
|
||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||
|
||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||
@@ -34,7 +45,9 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
|
||||
const tvEnglish = await metadataProvider.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
});
|
||||
data.overview = tvEnglish.overview;
|
||||
}
|
||||
|
||||
@@ -53,10 +66,18 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
});
|
||||
|
||||
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const season = await tmdb.getTvSeason({
|
||||
const tmdb = new TheMovieDb();
|
||||
const tmdbTv = await tmdb.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
});
|
||||
const metadataProvider = tmdbTv.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
|
||||
const season = await metadataProvider.getTvSeason({
|
||||
tvId: Number(req.params.id),
|
||||
seasonNumber: Number(req.params.seasonNumber),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
|
||||
@@ -292,9 +292,17 @@ export class MediaRequestSubscriber
|
||||
}
|
||||
|
||||
if (radarrSettings.tagRequests) {
|
||||
let userTag = (await radarr.getTags()).find((v) =>
|
||||
const radarrTags = await radarr.getTags();
|
||||
// old tags had space around the hyphen
|
||||
let userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||
);
|
||||
// new tags do not have spaces around the hyphen, since spaces are not allowed anymore
|
||||
if (!userTag) {
|
||||
userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + '-')
|
||||
);
|
||||
}
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
@@ -302,11 +310,11 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
@@ -601,9 +609,17 @@ export class MediaRequestSubscriber
|
||||
}
|
||||
|
||||
if (sonarrSettings.tagRequests) {
|
||||
let userTag = (await sonarr.getTags()).find((v) =>
|
||||
const sonarrTags = await sonarr.getTags();
|
||||
// old tags had space around the hyphen
|
||||
let userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||
);
|
||||
// new tags do not have spaces around the hyphen, since spaces are not allowed anymore
|
||||
if (!userTag) {
|
||||
userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + '-')
|
||||
);
|
||||
}
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
@@ -611,11 +627,11 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
|
||||
@@ -53,10 +53,11 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
b(style='color: #9ca3af; font-weight: 700;')
|
||||
| #{extra.name}
|
||||
| #{extra.value}
|
||||
td(rowspan='2' style='width: 7rem;')
|
||||
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
|
||||
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
|
||||
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
|
||||
if imageUrl
|
||||
td(rowspan='2' style='width: 7rem;')
|
||||
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
|
||||
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
|
||||
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
|
||||
tr
|
||||
td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
|
||||
span
|
||||
|
||||
35
server/types/languages.d.ts
vendored
Normal file
35
server/types/languages.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
export type AvailableLocale =
|
||||
| 'ar'
|
||||
| 'bg'
|
||||
| 'ca'
|
||||
| 'cs'
|
||||
| 'da'
|
||||
| 'de'
|
||||
| 'en'
|
||||
| 'el'
|
||||
| 'es'
|
||||
| 'es-MX'
|
||||
| 'fi'
|
||||
| 'fr'
|
||||
| 'hr'
|
||||
| 'he'
|
||||
| 'hi'
|
||||
| 'hu'
|
||||
| 'it'
|
||||
| 'ja'
|
||||
| 'ko'
|
||||
| 'lt'
|
||||
| 'nb-NO'
|
||||
| 'nl'
|
||||
| 'pl'
|
||||
| 'pt-BR'
|
||||
| 'pt-PT'
|
||||
| 'ro'
|
||||
| 'ru'
|
||||
| 'sq'
|
||||
| 'sr'
|
||||
| 'sv'
|
||||
| 'tr'
|
||||
| 'uk'
|
||||
| 'zh-CN'
|
||||
| 'zh-TW';
|
||||
26
server/utils/dnsCache.ts
Normal file
26
server/utils/dnsCache.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import logger from '@server/logger';
|
||||
import { DnsCacheManager } from 'dns-caching';
|
||||
|
||||
export let dnsCache: DnsCacheManager | undefined;
|
||||
|
||||
export function initializeDnsCache({
|
||||
forceMinTtl,
|
||||
forceMaxTtl,
|
||||
}: {
|
||||
forceMinTtl?: number;
|
||||
forceMaxTtl?: number;
|
||||
}) {
|
||||
if (dnsCache) {
|
||||
logger.warn('DNS Cache is already initialized', { label: 'DNS Cache' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Initializing DNS Cache', { label: 'DNS Cache' });
|
||||
|
||||
dnsCache = new DnsCacheManager({
|
||||
logger,
|
||||
forceMinTtl: typeof forceMinTtl === 'number' ? forceMinTtl * 1000 : 0,
|
||||
forceMaxTtl: typeof forceMaxTtl === 'number' ? forceMaxTtl * 1000 : -1,
|
||||
});
|
||||
dnsCache.initialize();
|
||||
}
|
||||
@@ -84,6 +84,7 @@ const SettingsTabs = ({
|
||||
Select a Tab
|
||||
</label>
|
||||
<select
|
||||
id="tabs"
|
||||
onChange={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
|
||||
@@ -33,6 +33,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
studio: 'Studio',
|
||||
genres: 'Genres',
|
||||
keywords: 'Keywords',
|
||||
excludeKeywords: 'Exclude Keywords',
|
||||
originalLanguage: 'Original Language',
|
||||
runtimeText: '{minValue}-{maxValue} minute runtime',
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
@@ -181,6 +182,19 @@ const FilterSlideover = ({
|
||||
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.excludeKeywords)}
|
||||
</span>
|
||||
<KeywordSelector
|
||||
defaultValue={currentFilters.excludeKeywords}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
'excludeKeywords',
|
||||
value?.map((v) => v.value).join(',')
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.originalLanguage)}
|
||||
</span>
|
||||
|
||||
@@ -99,6 +99,7 @@ export const QueryFilterOptions = z.object({
|
||||
studio: z.string().optional(),
|
||||
genre: z.string().optional(),
|
||||
keywords: z.string().optional(),
|
||||
excludeKeywords: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
withRuntimeGte: z.string().optional(),
|
||||
withRuntimeLte: z.string().optional(),
|
||||
@@ -161,6 +162,10 @@ export const prepareFilterValues = (
|
||||
filterValues.keywords = values.keywords;
|
||||
}
|
||||
|
||||
if (values.excludeKeywords) {
|
||||
filterValues.excludeKeywords = values.excludeKeywords;
|
||||
}
|
||||
|
||||
if (values.language) {
|
||||
filterValues.language = values.language;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { issueOptions } from '@app/components/IssueModal/constants';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
@@ -26,6 +27,7 @@ const messages = defineMessages('components.IssueList.IssueItem', {
|
||||
opened: 'Opened',
|
||||
viewissue: 'View Issue',
|
||||
unknownissuetype: 'Unknown',
|
||||
descriptionpreview: 'Issue Description',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -107,8 +109,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const description = issue.comments?.[0]?.message || '';
|
||||
const maxDescriptionLength = 120;
|
||||
const shouldTruncate = description.length > maxDescriptionLength;
|
||||
const truncatedDescription = shouldTruncate
|
||||
? description.substring(0, maxDescriptionLength) + '...'
|
||||
: description;
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
@@ -168,8 +177,38 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
>
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</Link>
|
||||
{description && (
|
||||
<div className="mt-1 max-w-full">
|
||||
<div className="overflow-hidden text-sm text-gray-300">
|
||||
{shouldTruncate ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="max-w-sm p-3">
|
||||
<div className="mb-1 text-sm font-medium text-gray-200">
|
||||
Issue Description
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
tooltipConfig={{
|
||||
placement: 'top',
|
||||
offset: [0, 8],
|
||||
}}
|
||||
>
|
||||
<span className="block cursor-help truncate transition-colors hover:text-gray-200">
|
||||
{truncatedDescription}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="block break-words">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{problemSeasonEpisodeLine.length > 0 && (
|
||||
<div className="card-field">
|
||||
<div className="card-field mt-1">
|
||||
{problemSeasonEpisodeLine.map((t, k) => (
|
||||
<span key={k}>{t}</span>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { availableLanguages } from '@app/context/LanguageContext';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { LanguageIcon } from '@heroicons/react/24/solid';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
||||
import SearchInput from '@app/components/Layout/SearchInput';
|
||||
import Sidebar from '@app/components/Layout/Sidebar';
|
||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
91
src/components/MetadataSelector/index.tsx
Normal file
91
src/components/MetadataSelector/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select, { type StylesConfig } from 'react-select';
|
||||
|
||||
enum MetadataProviderType {
|
||||
TMDB = 'tmdb',
|
||||
TVDB = 'tvdb',
|
||||
}
|
||||
|
||||
type MetadataProviderOptionType = {
|
||||
testId?: string;
|
||||
value: MetadataProviderType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const messages = defineMessages('components.MetadataSelector', {
|
||||
tmdbLabel: 'The Movie Database (TMDB)',
|
||||
tvdbLabel: 'TheTVDB',
|
||||
selectMetdataProvider: 'Select a metadata provider',
|
||||
});
|
||||
|
||||
interface MetadataSelectorProps {
|
||||
testId: string;
|
||||
value: MetadataProviderType;
|
||||
onChange: (value: MetadataProviderType) => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const MetadataSelector = ({
|
||||
testId = 'metadata-provider-selector',
|
||||
value,
|
||||
onChange,
|
||||
isDisabled = false,
|
||||
}: MetadataSelectorProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const metadataProviderOptions: MetadataProviderOptionType[] = [
|
||||
{
|
||||
testId: 'tmdb-option',
|
||||
value: MetadataProviderType.TMDB,
|
||||
label: intl.formatMessage(messages.tmdbLabel),
|
||||
},
|
||||
{
|
||||
testId: 'tvdb-option',
|
||||
value: MetadataProviderType.TVDB,
|
||||
label: intl.formatMessage(messages.tvdbLabel),
|
||||
},
|
||||
];
|
||||
|
||||
const customStyles: StylesConfig<MetadataProviderOptionType, false> = {
|
||||
option: (base) => ({
|
||||
...base,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
};
|
||||
|
||||
const formatOptionLabel = (option: MetadataProviderOptionType) => (
|
||||
<div className="flex items-center">
|
||||
<span data-testid={option.testId}>{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<Select
|
||||
options={metadataProviderOptions}
|
||||
isDisabled={isDisabled}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={metadataProviderOptions.find((option) => option.value === value)}
|
||||
onChange={(selectedOption) => {
|
||||
if (selectedOption) {
|
||||
onChange(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
placeholder={intl.formatMessage(messages.selectMetdataProvider)}
|
||||
styles={customStyles}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { MetadataProviderType };
|
||||
export default MetadataSelector;
|
||||
@@ -99,7 +99,7 @@ const messages = defineMessages('components.MovieDetails', {
|
||||
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
imdbuserscore: 'IMDB User Score',
|
||||
imdbuserscore: 'IMDB User Score – votes: {formattedCount}',
|
||||
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
|
||||
watchlistDeleted:
|
||||
'<strong>{title}</strong> Removed from watchlist successfully!',
|
||||
@@ -812,7 +812,18 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.imdb?.criticsScore && (
|
||||
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.imdbuserscore, {
|
||||
formattedCount: intl.formatNumber(
|
||||
ratingData.imdb.criticsScoreCount,
|
||||
{
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
}
|
||||
),
|
||||
})}
|
||||
>
|
||||
<a
|
||||
href={ratingData.imdb.url}
|
||||
className="media-rating"
|
||||
|
||||
@@ -152,7 +152,6 @@ const PWAHeader = ({ applicationTitle = 'Jellyseerr' }: PWAHeaderProps) => {
|
||||
href="/apple-splash-1136-640.jpg"
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import RequestModal from '@app/components/RequestModal';
|
||||
import useRequestOverride from '@app/hooks/useRequestOverride';
|
||||
@@ -95,36 +96,58 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||
<div className="white mb-1 flex flex-nowrap">
|
||||
<Tooltip content={intl.formatMessage(messages.requestedby)}>
|
||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<span className="flex w-40 items-center truncate md:w-auto">
|
||||
<Tooltip content={intl.formatMessage(messages.requestedby)}>
|
||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<Link
|
||||
href={
|
||||
request.requestedBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${request.requestedBy.id}`
|
||||
}
|
||||
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={request.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</span>
|
||||
{request.requestedBy.displayName}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
{request.modifiedBy && (
|
||||
<div className="flex flex-nowrap">
|
||||
<Tooltip content={intl.formatMessage(messages.lastmodifiedby)}>
|
||||
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<span className="flex w-40 items-center truncate md:w-auto">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.lastmodifiedby)}
|
||||
>
|
||||
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<Link
|
||||
href={
|
||||
request.modifiedBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${request.modifiedBy.id}`
|
||||
}
|
||||
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={request.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</span>
|
||||
{request.modifiedBy.displayName}
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Settings.Notifications', {
|
||||
agentenabled: 'Enable Agent',
|
||||
embedPoster: 'Embed Poster',
|
||||
botUsername: 'Bot Username',
|
||||
botAvatarUrl: 'Bot Avatar URL',
|
||||
webhookUrl: 'Webhook URL',
|
||||
@@ -74,6 +75,7 @@ const NotificationsDiscord = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
embedPoster: data.embedPoster,
|
||||
types: data.types,
|
||||
botUsername: data?.options.botUsername,
|
||||
botAvatarUrl: data?.options.botAvatarUrl,
|
||||
@@ -86,6 +88,7 @@ const NotificationsDiscord = () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/discord', {
|
||||
enabled: values.enabled,
|
||||
embedPoster: values.embedPoster,
|
||||
types: values.types,
|
||||
options: {
|
||||
botUsername: values.botUsername,
|
||||
@@ -135,6 +138,7 @@ const NotificationsDiscord = () => {
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/discord/test', {
|
||||
enabled: true,
|
||||
embedPoster: values.embedPoster,
|
||||
types: values.types,
|
||||
options: {
|
||||
botUsername: values.botUsername,
|
||||
@@ -176,6 +180,14 @@ const NotificationsDiscord = () => {
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="embedPoster" className="checkbox-label">
|
||||
{intl.formatMessage(messages.embedPoster)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
|
||||
@@ -17,6 +17,7 @@ const messages = defineMessages('components.Settings.Notifications', {
|
||||
validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
|
||||
validationSmtpPortRequired: 'You must provide a valid port number',
|
||||
agentenabled: 'Enable Agent',
|
||||
embedPoster: 'Embed Poster',
|
||||
userEmailRequired: 'Require user email',
|
||||
emailsender: 'Sender Address',
|
||||
smtpHost: 'SMTP Host',
|
||||
@@ -122,6 +123,7 @@ const NotificationsEmail = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
embedPoster: data.embedPoster,
|
||||
userEmailRequired: data.options.userEmailRequired,
|
||||
emailFrom: data.options.emailFrom,
|
||||
smtpHost: data.options.smtpHost,
|
||||
@@ -145,6 +147,7 @@ const NotificationsEmail = () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/email', {
|
||||
enabled: values.enabled,
|
||||
embedPoster: values.embedPoster,
|
||||
options: {
|
||||
userEmailRequired: values.userEmailRequired,
|
||||
emailFrom: values.emailFrom,
|
||||
@@ -194,6 +197,7 @@ const NotificationsEmail = () => {
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/email/test', {
|
||||
enabled: true,
|
||||
embedPoster: values.embedPoster,
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
@@ -241,6 +245,14 @@ const NotificationsEmail = () => {
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="embedPoster" className="checkbox-label">
|
||||
{intl.formatMessage(messages.embedPoster)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="userEmailRequired" className="checkbox-label">
|
||||
{intl.formatMessage(messages.userEmailRequired)}
|
||||
|
||||
@@ -19,6 +19,7 @@ const messages = defineMessages(
|
||||
'components.Settings.Notifications.NotificationsNtfy',
|
||||
{
|
||||
agentenabled: 'Enable Agent',
|
||||
embedPoster: 'Embed Poster',
|
||||
url: 'Server root URL',
|
||||
topic: 'Topic',
|
||||
usernamePasswordAuth: 'Username + Password authentication',
|
||||
@@ -80,6 +81,7 @@ const NotificationsNtfy = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data?.enabled,
|
||||
embedPoster: data?.embedPoster,
|
||||
types: data?.types,
|
||||
url: data?.options.url,
|
||||
topic: data?.options.topic,
|
||||
@@ -94,6 +96,7 @@ const NotificationsNtfy = () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/ntfy', {
|
||||
enabled: values.enabled,
|
||||
embedPoster: values.embedPoster,
|
||||
types: values.types,
|
||||
options: {
|
||||
url: values.url,
|
||||
@@ -188,6 +191,14 @@ const NotificationsNtfy = () => {
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="embedPoster" className="checkbox-label">
|
||||
{intl.formatMessage(messages.embedPoster)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="url" className="text-label">
|
||||
{intl.formatMessage(messages.url)}
|
||||
|
||||
@@ -17,6 +17,7 @@ const messages = defineMessages(
|
||||
'components.Settings.Notifications.NotificationsPushover',
|
||||
{
|
||||
agentenabled: 'Enable Agent',
|
||||
embedPoster: 'Embed Poster',
|
||||
accessToken: 'Application API Token',
|
||||
accessTokenTip:
|
||||
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
|
||||
@@ -86,6 +87,7 @@ const NotificationsPushover = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data?.enabled,
|
||||
embedPoster: data?.embedPoster,
|
||||
types: data?.types,
|
||||
accessToken: data?.options.accessToken,
|
||||
userToken: data?.options.userToken,
|
||||
@@ -96,6 +98,7 @@ const NotificationsPushover = () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/pushover', {
|
||||
enabled: values.enabled,
|
||||
embedPoster: values.embedPoster,
|
||||
types: values.types,
|
||||
options: {
|
||||
accessToken: values.accessToken,
|
||||
@@ -142,6 +145,7 @@ const NotificationsPushover = () => {
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/pushover/test', {
|
||||
enabled: true,
|
||||
embedPoster: values.embedPoster,
|
||||
types: values.types,
|
||||
options: {
|
||||
accessToken: values.accessToken,
|
||||
@@ -181,6 +185,14 @@ const NotificationsPushover = () => {
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="embedPoster" className="checkbox-label">
|
||||
{intl.formatMessage(messages.embedPoster)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="accessToken" className="text-label">
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user