Compare commits
88 Commits
fix-user-p
...
preview-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906ca61b60 | ||
|
|
0342127058 | ||
|
|
9aeb3604e6 | ||
|
|
6eb88f8674 | ||
|
|
46ee8a4ca1 | ||
|
|
f52939e4cd | ||
|
|
d31a2c37e6 | ||
|
|
20863d4a8d | ||
|
|
4757f1c3e5 | ||
|
|
1f1ad72e9e | ||
|
|
c3ddc860b6 | ||
|
|
2bd125d9a5 | ||
|
|
7a5e8d69bf | ||
|
|
650c339d74 | ||
|
|
4ef5a3c7c5 | ||
|
|
a791b53953 | ||
|
|
68467ced9d | ||
|
|
296aee6338 | ||
|
|
0a4b38e50d | ||
|
|
bcc84d8551 | ||
|
|
783fda9621 | ||
|
|
d765055da8 | ||
|
|
fed66f0702 | ||
|
|
461202da75 | ||
|
|
0bbcfdc4f9 | ||
|
|
f486fb5e75 | ||
|
|
10082292e8 | ||
|
|
c0a0b9c8a8 | ||
|
|
d9d07c705a | ||
|
|
0eea1090df | ||
|
|
cd0fa3e223 | ||
|
|
9c68616343 | ||
|
|
0c2713213c | ||
|
|
3856061fe1 | ||
|
|
0900a95532 | ||
|
|
0c86684bc2 | ||
|
|
010df62776 | ||
|
|
530be4272c | ||
|
|
c2e87714b4 | ||
|
|
eee9a025d2 | ||
|
|
aed011a557 | ||
|
|
ea47dd3571 | ||
|
|
4c9013729e | ||
|
|
3eb1bb3d8f | ||
|
|
db84f6529a | ||
|
|
4f81788386 | ||
|
|
72d3f9b908 | ||
|
|
333ffed7f0 | ||
|
|
8641a26771 | ||
|
|
7329524868 | ||
|
|
908dcb487a | ||
|
|
d486d58d3d | ||
|
|
d8b08f4c6b | ||
|
|
a48a337e0f | ||
|
|
981f5e679c | ||
|
|
7af193b8f6 | ||
|
|
6040e16645 | ||
|
|
3877301fc8 | ||
|
|
092a1458a4 | ||
|
|
1c68111b12 | ||
|
|
0e777ddb1e | ||
|
|
52c689b080 | ||
|
|
1a11f085ba | ||
|
|
c0234582a6 | ||
|
|
fd958d6347 | ||
|
|
6586db52dc | ||
|
|
a41cb8b004 | ||
|
|
de66222e7a | ||
|
|
eb790cb466 | ||
|
|
0680931332 | ||
|
|
ff2821471e | ||
|
|
e032c02f5f | ||
|
|
f8c4def229 | ||
|
|
a0415e7b6b | ||
|
|
b5f672785a | ||
|
|
770d788fd7 | ||
|
|
c58261c841 | ||
|
|
ccfcdea1f6 | ||
|
|
8ec8f2ac57 | ||
|
|
91f97f96ab | ||
|
|
f4051a1e5d | ||
|
|
f564cddff4 | ||
|
|
cfcce6acf0 | ||
|
|
b85d7f37b9 | ||
|
|
97396c2f57 | ||
|
|
0dfe050ba1 | ||
|
|
13dd3cad54 | ||
|
|
ce9802d5d4 |
@@ -277,6 +277,105 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "mdll23",
|
||||||
|
"name": "Michael Dallinger",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/142844478?v=4",
|
||||||
|
"profile": "https://github.com/mdll23",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "xeruf",
|
||||||
|
"name": "Janek",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
|
||||||
|
"profile": "https://github.com/xeruf",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "aleksasiriski",
|
||||||
|
"name": "Aleksa Siriški",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/31509435?v=4",
|
||||||
|
"profile": "https://aleksasiriski.dev",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Danish-H",
|
||||||
|
"name": "Danish Humair",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/121830048?v=4",
|
||||||
|
"profile": "http://danishhumair.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "trackmastersteve",
|
||||||
|
"name": "Stephen Harris",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/16858514?v=4",
|
||||||
|
"profile": "https://arm0.red",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "joshuaboniface",
|
||||||
|
"name": "Joshua M. Boniface",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4031396?v=4",
|
||||||
|
"profile": "https://www.boniface.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "gauthier-th",
|
||||||
|
"name": "Gauthier",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||||
|
"profile": "https://gauthierth.fr/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Kara-Zor-El",
|
||||||
|
"name": "Kara",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/69772087?v=4",
|
||||||
|
"profile": "https://github.com/Kara-Zor-El",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "JoaquinOlivero",
|
||||||
|
"name": "Joaquin Olivero",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/66050823?v=4",
|
||||||
|
"profile": "https://joaquinolivero.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Bretterteig",
|
||||||
|
"name": "Julian Behr",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/47298401?v=4",
|
||||||
|
"profile": "https://github.com/Bretterteig",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ThowZzy",
|
||||||
|
"name": "ThowZzy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/61882536?v=4",
|
||||||
|
"profile": "https://github.com/ThowZzy",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
|||||||
github: [Fallenbagel]
|
buy_me_a_coffee: fallen.bagel
|
||||||
|
|||||||
14
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
container: node:18.18-alpine
|
container: node:18.18-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
@@ -34,18 +34,18 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OWNER: ${{ github.repository_owner }}
|
OWNER: ${{ github.repository_owner }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
2
.github/workflows/codeql.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v2
|
||||||
|
|||||||
26
.github/workflows/conflict_labeler.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Merge Conflict Labeler
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
pull_request_target:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
types: [synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
name: Labeling
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.repository == 'Fallenbagel/jellyseerr' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Apply label
|
||||||
|
uses: eps1lon/actions-label-merge-conflict@v3
|
||||||
|
with:
|
||||||
|
dirtyLabel: 'merge conflict'
|
||||||
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
4
.github/workflows/cypress.yml
vendored
@@ -13,9 +13,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Cypress run
|
- name: Cypress run
|
||||||
uses: cypress-io/github-action@v4
|
uses: cypress-io/github-action@v6
|
||||||
with:
|
with:
|
||||||
build: yarn cypress:build
|
build: yarn cypress:build
|
||||||
start: yarn start
|
start: yarn start
|
||||||
|
|||||||
12
.github/workflows/preview.yml
vendored
@@ -11,25 +11,25 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Get the version
|
- name: Get the version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
|||||||
120
.github/workflows/release.yml
vendored
@@ -10,19 +10,19 @@ jobs:
|
|||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -35,60 +35,60 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
run: npx semantic-release
|
run: npx semantic-release
|
||||||
|
|
||||||
build-snap:
|
# build-snap:
|
||||||
name: Build Snap Package (${{ matrix.architecture }})
|
# name: Build Snap Package (${{ matrix.architecture }})
|
||||||
needs: semantic-release
|
# needs: semantic-release
|
||||||
runs-on: ubuntu-22.04
|
# runs-on: ubuntu-22.04
|
||||||
strategy:
|
# strategy:
|
||||||
fail-fast: false
|
# fail-fast: false
|
||||||
matrix:
|
# matrix:
|
||||||
architecture:
|
# architecture:
|
||||||
- amd64
|
# - amd64
|
||||||
- arm64
|
# - arm64
|
||||||
- armhf
|
# - armhf
|
||||||
steps:
|
# steps:
|
||||||
- name: Checkout Code
|
# - name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
# uses: actions/checkout@v4
|
||||||
with:
|
# with:
|
||||||
fetch-depth: 0
|
# fetch-depth: 0
|
||||||
- name: Switch to main branch
|
# - name: Switch to main branch
|
||||||
run: git checkout main
|
# run: git checkout main
|
||||||
- name: Pull latest changes
|
# - name: Pull latest changes
|
||||||
run: git pull
|
# run: git pull
|
||||||
- name: Prepare
|
# - name: Prepare
|
||||||
id: prepare
|
# id: prepare
|
||||||
run: |
|
# run: |
|
||||||
git fetch --prune --tags
|
# git fetch --prune --tags
|
||||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
# echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
else
|
# else
|
||||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
# echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
fi
|
# fi
|
||||||
- name: Set Up QEMU
|
# - name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
# uses: docker/setup-qemu-action@v3
|
||||||
with:
|
# with:
|
||||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
# image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||||
- name: Build Snap Package
|
# - name: Build Snap Package
|
||||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
# uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||||
id: build
|
# id: build
|
||||||
with:
|
# with:
|
||||||
architecture: ${{ matrix.architecture }}
|
# architecture: ${{ matrix.architecture }}
|
||||||
- name: Upload Snap Package
|
# - name: Upload Snap Package
|
||||||
uses: actions/upload-artifact@v2
|
# uses: actions/upload-artifact@v4
|
||||||
with:
|
# with:
|
||||||
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
# name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||||
path: ${{ steps.build.outputs.snap }}
|
# path: ${{ steps.build.outputs.snap }}
|
||||||
- name: Review Snap Package
|
# - name: Review Snap Package
|
||||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
# uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||||
with:
|
# with:
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
# snap: ${{ steps.build.outputs.snap }}
|
||||||
- name: Publish Snap Package
|
# - name: Publish Snap Package
|
||||||
uses: snapcore/action-publish@v1
|
# uses: snapcore/action-publish@v1
|
||||||
env:
|
# env:
|
||||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
# SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
with:
|
# with:
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
# snap: ${{ steps.build.outputs.snap }}
|
||||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
# release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
|
|||||||
20
.github/workflows/snap.yaml
vendored
@@ -1,9 +1,13 @@
|
|||||||
name: Publish Snap
|
name: Publish Snap
|
||||||
|
|
||||||
on:
|
# turn off edge snap builds temporarily and make it manual
|
||||||
push:
|
|
||||||
branches:
|
# on:
|
||||||
- develop
|
# push:
|
||||||
|
# branches:
|
||||||
|
# - develop
|
||||||
|
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,7 +16,7 @@ jobs:
|
|||||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||||
steps:
|
steps:
|
||||||
- name: Cancel Previous Runs
|
- name: Cancel Previous Runs
|
||||||
uses: styfle/cancel-workflow-action@0.10.0
|
uses: styfle/cancel-workflow-action@0.12.1
|
||||||
with:
|
with:
|
||||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ jobs:
|
|||||||
- armhf
|
- armhf
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
id: prepare
|
id: prepare
|
||||||
run: |
|
run: |
|
||||||
@@ -40,7 +44,7 @@ jobs:
|
|||||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Configure Git
|
- name: Configure Git
|
||||||
run: git config --add safe.directory /data/parts/jellyseerr/src
|
run: git config --add safe.directory /data/parts/jellyseerr/src
|
||||||
- name: Build Snap Package
|
- name: Build Snap Package
|
||||||
@@ -49,7 +53,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
architecture: ${{ matrix.architecture }}
|
architecture: ${{ matrix.architecture }}
|
||||||
- name: Upload Snap Package
|
- name: Upload Snap Package
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||||
path: ${{ steps.build.outputs.snap }}
|
path: ${{ steps.build.outputs.snap }}
|
||||||
|
|||||||
4
.github/workflows/support.yml
vendored
@@ -6,9 +6,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
support:
|
support:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/support-requests@v2
|
- uses: dessant/support-requests@v4
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
support-label: 'support'
|
support-label: 'support'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Contributing to Overseerr
|
# Contributing to Jellyseerr
|
||||||
|
|
||||||
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
|
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
|
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
git clone https://github.com/YOUR_USERNAME/jellyseerr.git
|
||||||
cd overseerr/
|
cd overseerr/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,9 +97,9 @@ When adding new UI text, please try to adhere to the following guidelines:
|
|||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
|
|||||||
50
README.md
@@ -2,23 +2,28 @@
|
|||||||
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
<img src="https://github.com/Fallenbagel/jellyseerr/actions/workflows/release.yml/badge.svg" alt="Jellyseerr Release" />
|
||||||
|
<img src="https://github.com/Fallenbagel/jellyseerr/actions/workflows/ci.yml/badge.svg" alt="Jellyseerr CI">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||||
|
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/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>
|
<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 -->
|
<!-- 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-29-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||||
|
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
|
||||||
|
|
||||||
_The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_
|
_The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_
|
||||||
|
|
||||||
## Current Features
|
## Current Features
|
||||||
|
|
||||||
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
|
- Full Jellyfin/Emby/Plex integration including authentication with user import & management
|
||||||
- Supports Movies, Shows, Mixed Libraries!
|
- Supports Movies, Shows and Mixed Libraries
|
||||||
- Ability to change email addresses for smtp purposes
|
- Ability to change email addresses for smtp purposes
|
||||||
- Ability to import all jellyfin/emby users
|
|
||||||
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
||||||
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
|
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
|
||||||
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||||
@@ -35,11 +40,11 @@ With more features on the way! Check out our [issue tracker](https://github.com/
|
|||||||
|
|
||||||
#### Pre-requisite (Important)
|
#### Pre-requisite (Important)
|
||||||
|
|
||||||
_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
|
_*On Jellyfin/Emby, ensure the `Settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
|
||||||
|
|
||||||
### Launching Jellyseerr using Docker (Recommended)
|
### Launching Jellyseerr using Docker (Recommended)
|
||||||
|
|
||||||
Check out our dockerhub for instructions on how to install and run Jellyseerr:
|
Check out our docker hub for instructions on how to install and run Jellyseerr:
|
||||||
https://hub.docker.com/r/fallenbagel/jellyseerr
|
https://hub.docker.com/r/fallenbagel/jellyseerr
|
||||||
|
|
||||||
### Building from source (ADVANCED):
|
### Building from source (ADVANCED):
|
||||||
@@ -49,7 +54,7 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
|
|||||||
Pre-requisites:
|
Pre-requisites:
|
||||||
|
|
||||||
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
|
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
|
||||||
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
|
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
|
||||||
- Download/git clone the source code from the github (Either develop branch or main for stable)
|
- Download/git clone the source code from the github (Either develop branch or main for stable)
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
@@ -59,16 +64,17 @@ yarn install --frozen-lockfile --network-timeout 1000000
|
|||||||
yarn run build
|
yarn run build
|
||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
(you can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background)
|
|
||||||
|
|
||||||
_to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_
|
(You can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background)
|
||||||
|
|
||||||
|
_To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
**Pre-requisites:**
|
**Pre-requisites:**
|
||||||
|
|
||||||
- Nodejs [v18](https://nodejs.org/en/download/package-manager)
|
- Nodejs [v18](https://nodejs.org/en/download/package-manager)
|
||||||
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
|
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
@@ -79,7 +85,7 @@ _to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` i
|
|||||||
cd /opt
|
cd /opt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Then clone the follow commands to clone and checkout to the stable version
|
2. Then execute the following commands to clone and checkout to the stable version
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
|
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
|
||||||
@@ -98,9 +104,9 @@ yarn run build
|
|||||||
5. If you want to run jellyseerr as a _Systemd-service:_
|
5. If you want to run jellyseerr as a _Systemd-service:_
|
||||||
|
|
||||||
- assuming jellyseerr was cloned to `/opt/`
|
- assuming jellyseerr was cloned to `/opt/`
|
||||||
- first create the environmentfile at `/etc/jellyseerr/jellyseerr.conf`
|
- first create the environment file at `/etc/jellyseerr/jellyseerr.conf`
|
||||||
|
|
||||||
Environmentfile:
|
Environment file:
|
||||||
|
|
||||||
```
|
```
|
||||||
# Jellyseerr's default port is 5055, if you want to use both, change this.
|
# Jellyseerr's default port is 5055, if you want to use both, change this.
|
||||||
@@ -136,6 +142,7 @@ ExecStart=/usr/bin/node dist/index.js
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
### Packages:
|
### Packages:
|
||||||
|
|
||||||
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
||||||
@@ -217,6 +224,19 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -368,6 +368,9 @@ components:
|
|||||||
externalHostname:
|
externalHostname:
|
||||||
type: string
|
type: string
|
||||||
example: 'http://my.jellyfin.host'
|
example: 'http://my.jellyfin.host'
|
||||||
|
jellyfinForgotPasswordUrl:
|
||||||
|
type: string
|
||||||
|
example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html'
|
||||||
adminUser:
|
adminUser:
|
||||||
type: string
|
type: string
|
||||||
example: 'admin'
|
example: 'admin'
|
||||||
@@ -2089,6 +2092,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
/settings/jellyfin/sync:
|
/settings/jellyfin/sync:
|
||||||
get:
|
get:
|
||||||
summary: Get status of full Jellyfin library sync
|
summary: Get status of full Jellyfin library sync
|
||||||
@@ -3392,6 +3402,12 @@ paths:
|
|||||||
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
|
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
|
||||||
tags:
|
tags:
|
||||||
- settings
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: sliderId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3721,7 +3737,7 @@ paths:
|
|||||||
results:
|
results:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
post:
|
post:
|
||||||
summary: Create new user
|
summary: Create new user
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"axios-rate-limit": "1.3.0",
|
"axios-rate-limit": "1.3.0",
|
||||||
"bcrypt": "5.1.0",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
|
"cacheable-lookup": "^7.0.0",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.6",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 38 KiB |
BIN
public/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
BIN
public/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
BIN
public/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 33 KiB |
BIN
public/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 37 KiB |
BIN
public/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 821 B |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 137 KiB |
@@ -1,13 +1,22 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import type { AxiosInstance } from 'axios';
|
import { ApiError } from '@server/types/error';
|
||||||
import axios from 'axios';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
|
||||||
export interface JellyfinUserResponse {
|
export interface JellyfinUserResponse {
|
||||||
Name: string;
|
Name: string;
|
||||||
ServerId: string;
|
ServerId: string;
|
||||||
ServerName: string;
|
ServerName: string;
|
||||||
Id: string;
|
Id: string;
|
||||||
|
Configuration: {
|
||||||
|
GroupedFolders: string[];
|
||||||
|
};
|
||||||
|
Policy: {
|
||||||
|
IsAdministrator: boolean;
|
||||||
|
};
|
||||||
PrimaryImageTag?: string;
|
PrimaryImageTag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +29,13 @@ export interface JellyfinUserListResponse {
|
|||||||
users: JellyfinUserResponse[];
|
users: JellyfinUserResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface JellyfinMediaFolder {
|
||||||
|
Name: string;
|
||||||
|
Id: string;
|
||||||
|
Type: string;
|
||||||
|
CollectionType: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JellyfinLibrary {
|
export interface JellyfinLibrary {
|
||||||
type: 'show' | 'movie';
|
type: 'show' | 'movie';
|
||||||
key: string;
|
key: string;
|
||||||
@@ -76,48 +92,84 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
DateCreated?: string;
|
DateCreated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class JellyfinAPI {
|
class JellyfinAPI extends ExternalAPI {
|
||||||
private authToken?: string;
|
private authToken?: string;
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
private jellyfinHost: string;
|
private jellyfinHost: string;
|
||||||
private axios: AxiosInstance;
|
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||||
this.jellyfinHost = jellyfinHost;
|
let authHeaderVal: string;
|
||||||
this.authToken = authToken;
|
if (authToken) {
|
||||||
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||||
let authHeaderVal = '';
|
|
||||||
if (this.authToken) {
|
|
||||||
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
|
|
||||||
} else {
|
} else {
|
||||||
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`;
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.axios = axios.create({
|
super(
|
||||||
baseURL: this.jellyfinHost,
|
jellyfinHost,
|
||||||
headers: {
|
{},
|
||||||
'X-Emby-Authorization': authHeaderVal,
|
{
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
Accept: 'application/json',
|
'X-Emby-Authorization': authHeaderVal,
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
});
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.jellyfinHost = jellyfinHost;
|
||||||
|
this.authToken = authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
Username?: string,
|
Username?: string,
|
||||||
Password?: string
|
Password?: string,
|
||||||
|
ClientIP?: string
|
||||||
): Promise<JellyfinLoginResponse> {
|
): Promise<JellyfinLoginResponse> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.post<JellyfinLoginResponse>(
|
const headers = ClientIP
|
||||||
|
? {
|
||||||
|
'X-Forwarded-For': ClientIP,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const authResponse = await this.post<JellyfinLoginResponse>(
|
||||||
'/Users/AuthenticateByName',
|
'/Users/AuthenticateByName',
|
||||||
{
|
{
|
||||||
Username: Username,
|
Username: Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: headers,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return account.data;
|
|
||||||
|
return authResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Unauthorized');
|
const status = e.response?.status;
|
||||||
|
|
||||||
|
const networkErrorCodes = new Set([
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'EHOSTUNREACH',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ECONNRESET',
|
||||||
|
'EADDRINUSE',
|
||||||
|
'ENETDOWN',
|
||||||
|
'ENETUNREACH',
|
||||||
|
'EPIPE',
|
||||||
|
'ECONNABORTED',
|
||||||
|
'EPROTO',
|
||||||
|
'EHOSTDOWN',
|
||||||
|
'EAI_AGAIN',
|
||||||
|
'ERR_INVALID_URL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (networkErrorCodes.has(e.code) || status === 404) {
|
||||||
|
throw new ApiError(status, ApiErrorCode.InvalidUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,67 +180,94 @@ class JellyfinAPI {
|
|||||||
|
|
||||||
public async getServerName(): Promise<string> {
|
public async getServerName(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get<JellyfinUserResponse>(
|
const serverResponse = await this.get<JellyfinUserResponse>(
|
||||||
"/System/Info/Public'}"
|
'/System/Info/Public'
|
||||||
);
|
);
|
||||||
return account.data.ServerName;
|
|
||||||
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('girl idk');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUsers(): Promise<JellyfinUserListResponse> {
|
public async getUsers(): Promise<JellyfinUserListResponse> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get(`/Users`);
|
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
|
||||||
return { users: account.data };
|
|
||||||
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUser(): Promise<JellyfinUserResponse> {
|
public async getUser(): Promise<JellyfinUserResponse> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get<JellyfinUserResponse>(
|
const userReponse = await this.get<JellyfinUserResponse>(
|
||||||
`/Users/${this.userId ?? 'Me'}`
|
`/Users/${this.userId ?? 'Me'}`
|
||||||
);
|
);
|
||||||
return account.data;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
||||||
try {
|
try {
|
||||||
// TODO: Try to fix automatic grouping without fucking up LDAP users
|
const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`);
|
||||||
// const libraries = await this.axios.get<any>('/Library/VirtualFolders');
|
|
||||||
|
|
||||||
const account = await this.axios.get<any>(
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
`/Users/${this.userId ?? 'Me'}/Views`
|
} catch (mediaFoldersResponseError) {
|
||||||
);
|
// fallback to user views to get libraries
|
||||||
|
// this only and maybe/depending on factors affects LDAP users
|
||||||
|
try {
|
||||||
|
const mediaFolderResponse = await this.get<any>(
|
||||||
|
`/Users/${this.userId ?? 'Me'}/Views`
|
||||||
|
);
|
||||||
|
|
||||||
const response: JellyfinLibrary[] = account.data.Items.filter(
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
(Item: any) => {
|
} catch (e) {
|
||||||
return (
|
logger.error(
|
||||||
Item.Type === 'CollectionFolder' &&
|
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||||
Item.CollectionType !== 'music' &&
|
{ label: 'Jellyfin API' }
|
||||||
Item.CollectionType !== 'books' &&
|
);
|
||||||
Item.CollectionType !== 'musicvideos' &&
|
|
||||||
Item.CollectionType !== 'homevideos'
|
return [];
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
).map((Item: any) => {
|
}
|
||||||
|
|
||||||
|
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
|
||||||
|
const excludedTypes = [
|
||||||
|
'music',
|
||||||
|
'books',
|
||||||
|
'musicvideos',
|
||||||
|
'homevideos',
|
||||||
|
'boxsets',
|
||||||
|
];
|
||||||
|
|
||||||
|
return mediaFolders
|
||||||
|
.filter((Item: JellyfinMediaFolder) => {
|
||||||
|
return (
|
||||||
|
Item.Type === 'CollectionFolder' &&
|
||||||
|
!excludedTypes.includes(Item.CollectionType)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((Item: JellyfinMediaFolder) => {
|
||||||
return <JellyfinLibrary>{
|
return <JellyfinLibrary>{
|
||||||
key: Item.Id,
|
key: Item.Id,
|
||||||
title: Item.Name,
|
title: Item.Name,
|
||||||
@@ -196,24 +275,15 @@ class JellyfinAPI {
|
|||||||
agent: 'jellyfin',
|
agent: 'jellyfin',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
|
||||||
{ label: 'Jellyfin API' }
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const libraryItemsResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
|
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -221,55 +291,64 @@ class JellyfinAPI {
|
|||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const itemResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
|
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
|
public async getItemData(
|
||||||
|
id: string
|
||||||
|
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const itemResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items/${id}`
|
`/Users/${this.userId}/Items/${id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (availabilitySync.running) {
|
||||||
|
if (e.response && e.response.status === 500) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
|
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
|
const seasonResponse = await this.get<any>(`/Shows/${seriesID}/Seasons`);
|
||||||
|
|
||||||
return contents.data.Items.filter(
|
return seasonResponse.Items;
|
||||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,11 +357,11 @@ class JellyfinAPI {
|
|||||||
seasonID: string
|
seasonID: string
|
||||||
): Promise<JellyfinLibraryItem[]> {
|
): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const episodeResponse = await this.get<any>(
|
||||||
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data.Items.filter(
|
return episodeResponse.Items.filter(
|
||||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -290,7 +369,8 @@ class JellyfinAPI {
|
|||||||
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new Error('Invalid auth token');
|
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
server/constants/error.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export enum ApiErrorCode {
|
||||||
|
InvalidUrl = 'INVALID_URL',
|
||||||
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
|
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||||
|
NotAdmin = 'NOT_ADMIN',
|
||||||
|
Unknown = 'UNKNOWN',
|
||||||
|
}
|
||||||
@@ -151,11 +151,11 @@ class Media {
|
|||||||
@Column({ nullable: true, type: 'varchar' })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey4k?: string | null;
|
public ratingKey4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public jellyfinMediaId?: string;
|
public jellyfinMediaId?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public jellyfinMediaId4k?: string;
|
public jellyfinMediaId4k?: string | null;
|
||||||
|
|
||||||
public serviceUrl?: string;
|
public serviceUrl?: string;
|
||||||
public serviceUrl4k?: string;
|
public serviceUrl4k?: string;
|
||||||
|
|||||||
@@ -23,19 +23,25 @@ import imageproxy from '@server/routes/imageproxy';
|
|||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import restartFlag from '@server/utils/restartFlag';
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
|
import type CacheableLookupType from 'cacheable-lookup';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import csurf from 'csurf';
|
import csurf from 'csurf';
|
||||||
|
import { lookup } from 'dns';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as OpenApiValidator from 'express-openapi-validator';
|
import * as OpenApiValidator from 'express-openapi-validator';
|
||||||
import type { Store } from 'express-session';
|
import type { Store } from 'express-session';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import next from 'next';
|
import next from 'next';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import YAML from 'yamljs';
|
import YAML from 'yamljs';
|
||||||
|
|
||||||
|
const _importDynamic = new Function('modulePath', 'return import(modulePath)');
|
||||||
|
|
||||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||||
|
|
||||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||||
@@ -46,6 +52,25 @@ const handle = app.getRequestHandler();
|
|||||||
app
|
app
|
||||||
.prepare()
|
.prepare()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
|
||||||
|
.default as typeof CacheableLookupType;
|
||||||
|
const cacheable = new CacheableLookup();
|
||||||
|
|
||||||
|
const originalLookup = cacheable.lookup;
|
||||||
|
|
||||||
|
// if hostname is localhost use dns.lookup instead of cacheable-lookup
|
||||||
|
cacheable.lookup = (...args: any) => {
|
||||||
|
const [hostname] = args;
|
||||||
|
if (hostname === 'localhost') {
|
||||||
|
lookup(...(args as Parameters<typeof lookup>));
|
||||||
|
} else {
|
||||||
|
originalLookup(...(args as Parameters<typeof originalLookup>));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cacheable.install(http.globalAgent);
|
||||||
|
cacheable.install(https.globalAgent);
|
||||||
|
|
||||||
const dbConnection = await dataSource.initialize();
|
const dbConnection = await dataSource.initialize();
|
||||||
|
|
||||||
// Run migrations in production
|
// Run migrations in production
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface PublicSettingsResponse {
|
|||||||
jellyfinHost?: string;
|
jellyfinHost?: string;
|
||||||
jellyfinExternalHost?: string;
|
jellyfinExternalHost?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
|
jellyfinForgotPasswordUrl?: string;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
|
import {
|
||||||
|
jellyfinFullScanner,
|
||||||
|
jellyfinRecentScanner,
|
||||||
|
} from '@server/lib/scanners/jellyfin';
|
||||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||||
@@ -10,7 +15,6 @@ import watchlistSync from '@server/lib/watchlistsync';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import random from 'lodash/random';
|
import random from 'lodash/random';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
id: JobId;
|
id: JobId;
|
||||||
@@ -73,38 +77,38 @@ export const startJobs = (): void => {
|
|||||||
// Run recently added jellyfin sync every 5 minutes
|
// Run recently added jellyfin sync every 5 minutes
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-recently-added-scan',
|
id: 'jellyfin-recently-added-scan',
|
||||||
name: 'Jellyfin Recently Added Sync',
|
name: 'Jellyfin Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'minutes',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['jellyfin-recently-added-scan'].schedule,
|
jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
() => {
|
() => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
logger.info('Starting scheduled job: Jellyfin Recently Added Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
jobJellyfinRecentSync.run();
|
jellyfinRecentScanner.run();
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
running: () => jobJellyfinRecentSync.status().running,
|
running: () => jellyfinRecentScanner.status().running,
|
||||||
cancelFn: () => jobJellyfinRecentSync.cancel(),
|
cancelFn: () => jellyfinRecentScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run full jellyfin sync every 24 hours
|
// Run full jellyfin sync every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-full-scan',
|
id: 'jellyfin-full-scan',
|
||||||
name: 'Jellyfin Full Library Sync',
|
name: 'Jellyfin Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'hours',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
logger.info('Starting scheduled job: Jellyfin Full Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
jobJellyfinFullSync.run();
|
jellyfinFullScanner.run();
|
||||||
}),
|
}),
|
||||||
running: () => jobJellyfinFullSync.status().running,
|
running: () => jellyfinFullScanner.status().running,
|
||||||
cancelFn: () => jobJellyfinFullSync.cancel(),
|
cancelFn: () => jellyfinFullScanner.cancel(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +168,7 @@ export const startJobs = (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Checks if media is still available in plex/sonarr/radarr libs
|
// Checks if media is still available in plex/sonarr/radarr libs
|
||||||
/* scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'availability-sync',
|
id: 'availability-sync',
|
||||||
name: 'Media Availability Sync',
|
name: 'Media Availability Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
@@ -179,7 +183,6 @@ export const startJobs = (): void => {
|
|||||||
running: () => availabilitySync.running,
|
running: () => availabilitySync.running,
|
||||||
cancelFn: () => availabilitySync.cancel(),
|
cancelFn: () => availabilitySync.cancel(),
|
||||||
});
|
});
|
||||||
*/
|
|
||||||
|
|
||||||
// Run download sync every minute
|
// Run download sync every minute
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||||
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
import type { PlexMetadata } from '@server/api/plexapi';
|
import type { PlexMetadata } from '@server/api/plexapi';
|
||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||||
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import MediaRequest from '@server/entity/MediaRequest';
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
@@ -18,14 +21,20 @@ class AvailabilitySync {
|
|||||||
public running = false;
|
public running = false;
|
||||||
private plexClient: PlexAPI;
|
private plexClient: PlexAPI;
|
||||||
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
||||||
|
|
||||||
|
private jellyfinClient: JellyfinAPI;
|
||||||
|
private jellyfinSeasonsCache: Record<string, JellyfinLibraryItem[]>;
|
||||||
|
|
||||||
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||||
private radarrServers: RadarrSettings[];
|
private radarrServers: RadarrSettings[];
|
||||||
private sonarrServers: SonarrSettings[];
|
private sonarrServers: SonarrSettings[];
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
const mediaServerType = getSettings().main.mediaServerType;
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.plexSeasonsCache = {};
|
this.plexSeasonsCache = {};
|
||||||
|
this.jellyfinSeasonsCache = {};
|
||||||
this.sonarrSeasonsCache = {};
|
this.sonarrSeasonsCache = {};
|
||||||
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||||
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||||
@@ -37,13 +46,53 @@ class AvailabilitySync {
|
|||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
|
||||||
select: { id: true, plexToken: true },
|
|
||||||
where: { id: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (admin) {
|
// If it is plex admin is selected using plexToken if jellyfin admin is selected using jellyfinUserID
|
||||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
|
||||||
|
let admin = null;
|
||||||
|
|
||||||
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
|
admin = await userRepository.findOne({
|
||||||
|
select: { id: true, plexToken: true },
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
admin = await userRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinUserId',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
|
if (admin && admin.plexToken) {
|
||||||
|
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||||
|
} else {
|
||||||
|
logger.error('Plex admin is not configured.');
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
if (admin) {
|
||||||
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
|
settings.jellyfin.hostname ?? '',
|
||||||
|
admin.jellyfinAuthToken,
|
||||||
|
admin.jellyfinDeviceId
|
||||||
|
);
|
||||||
|
|
||||||
|
this.jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
|
} else {
|
||||||
|
logger.error('Jellyfin admin is not configured.');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error('An admin is not configured.');
|
logger.error('An admin is not configured.');
|
||||||
}
|
}
|
||||||
@@ -60,41 +109,84 @@ class AvailabilitySync {
|
|||||||
let movieExists = false;
|
let movieExists = false;
|
||||||
let movieExists4k = false;
|
let movieExists4k = false;
|
||||||
|
|
||||||
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
// if (mediaServerType === MediaServerType.PLEX) {
|
||||||
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
|
// await this.mediaExistsInPlex(media, false);
|
||||||
media,
|
// } else if (
|
||||||
true
|
// mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
);
|
// mediaServerType === MediaServerType.EMBY
|
||||||
|
// ) {
|
||||||
|
// await this.mediaExistsInJellyfin(media, false);
|
||||||
|
// }
|
||||||
|
|
||||||
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
|
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
|
||||||
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
|
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
|
||||||
|
|
||||||
if (existsInPlex || existsInRadarr) {
|
// plex
|
||||||
movieExists = true;
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
logger.info(
|
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||||
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
const { existsInPlex: existsInPlex4k } =
|
||||||
{
|
await this.mediaExistsInPlex(media, true);
|
||||||
label: 'AvailabilitySync',
|
|
||||||
}
|
if (existsInPlex || existsInRadarr) {
|
||||||
);
|
movieExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInPlex4k || existsInRadarr4k) {
|
||||||
|
movieExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsInPlex4k || existsInRadarr4k) {
|
//jellyfin
|
||||||
movieExists4k = true;
|
if (
|
||||||
logger.info(
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
mediaServerType === MediaServerType.EMBY
|
||||||
{
|
) {
|
||||||
label: 'AvailabilitySync',
|
const { existsInJellyfin } = await this.mediaExistsInJellyfin(
|
||||||
}
|
media,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
const { existsInJellyfin: existsInJellyfin4k } =
|
||||||
|
await this.mediaExistsInJellyfin(media, true);
|
||||||
|
|
||||||
|
if (existsInJellyfin || existsInRadarr) {
|
||||||
|
movieExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInJellyfin4k || existsInRadarr4k) {
|
||||||
|
movieExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
|
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
|
||||||
await this.mediaUpdater(media, false);
|
await this.mediaUpdater(media, false, mediaServerType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
|
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
|
||||||
await this.mediaUpdater(media, true);
|
await this.mediaUpdater(media, true, mediaServerType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +196,8 @@ class AvailabilitySync {
|
|||||||
let showExists = false;
|
let showExists = false;
|
||||||
let showExists4k = false;
|
let showExists4k = false;
|
||||||
|
|
||||||
|
//plex
|
||||||
|
|
||||||
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
|
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
|
||||||
await this.mediaExistsInPlex(media, false);
|
await this.mediaExistsInPlex(media, false);
|
||||||
const {
|
const {
|
||||||
@@ -111,6 +205,16 @@ class AvailabilitySync {
|
|||||||
seasonsMap: plexSeasonsMap4k = new Map(),
|
seasonsMap: plexSeasonsMap4k = new Map(),
|
||||||
} = await this.mediaExistsInPlex(media, true);
|
} = await this.mediaExistsInPlex(media, true);
|
||||||
|
|
||||||
|
//jellyfin
|
||||||
|
const {
|
||||||
|
existsInJellyfin,
|
||||||
|
seasonsMap: jellyfinSeasonsMap = new Map(),
|
||||||
|
} = await this.mediaExistsInJellyfin(media, false);
|
||||||
|
const {
|
||||||
|
existsInJellyfin: existsInJellyfin4k,
|
||||||
|
seasonsMap: jellyfinSeasonsMap4k = new Map(),
|
||||||
|
} = await this.mediaExistsInJellyfin(media, true);
|
||||||
|
|
||||||
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
|
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
|
||||||
await this.mediaExistsInSonarr(media, false);
|
await this.mediaExistsInSonarr(media, false);
|
||||||
const {
|
const {
|
||||||
@@ -118,24 +222,60 @@ class AvailabilitySync {
|
|||||||
seasonsMap: sonarrSeasonsMap4k,
|
seasonsMap: sonarrSeasonsMap4k,
|
||||||
} = await this.mediaExistsInSonarr(media, true);
|
} = await this.mediaExistsInSonarr(media, true);
|
||||||
|
|
||||||
if (existsInPlex || existsInSonarr) {
|
//plex
|
||||||
showExists = true;
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
logger.info(
|
if (existsInPlex || existsInSonarr) {
|
||||||
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
showExists = true;
|
||||||
{
|
logger.info(
|
||||||
label: 'AvailabilitySync',
|
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
}
|
{
|
||||||
);
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsInPlex4k || existsInSonarr4k) {
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
showExists4k = true;
|
if (existsInPlex4k || existsInSonarr4k) {
|
||||||
logger.info(
|
showExists4k = true;
|
||||||
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
logger.info(
|
||||||
{
|
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
label: 'AvailabilitySync',
|
{
|
||||||
}
|
label: 'AvailabilitySync',
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//jellyfin
|
||||||
|
if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
if (existsInJellyfin || existsInSonarr) {
|
||||||
|
showExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
if (existsInJellyfin4k || existsInSonarr4k) {
|
||||||
|
showExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here we will create a final map that will cross compare
|
// Here we will create a final map that will cross compare
|
||||||
@@ -155,11 +295,45 @@ class AvailabilitySync {
|
|||||||
filteredSeasonsMap.set(season.seasonNumber, false)
|
filteredSeasonsMap.set(season.seasonNumber, false)
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalSeasons = new Map([
|
// non-4k
|
||||||
...filteredSeasonsMap,
|
const finalSeasons: Map<number, boolean> = new Map();
|
||||||
...plexSeasonsMap,
|
|
||||||
...sonarrSeasonsMap,
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
]);
|
plexSeasonsMap.forEach((value, key) => {
|
||||||
|
finalSeasons.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredSeasonsMap.forEach((value, key) => {
|
||||||
|
if (!finalSeasons.has(key)) {
|
||||||
|
finalSeasons.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sonarrSeasonsMap.forEach((value, key) => {
|
||||||
|
if (!finalSeasons.has(key)) {
|
||||||
|
finalSeasons.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
jellyfinSeasonsMap.forEach((value, key) => {
|
||||||
|
finalSeasons.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredSeasonsMap.forEach((value, key) => {
|
||||||
|
if (!finalSeasons.has(key)) {
|
||||||
|
finalSeasons.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sonarrSeasonsMap.forEach((value, key) => {
|
||||||
|
if (!finalSeasons.has(key)) {
|
||||||
|
finalSeasons.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
@@ -173,18 +347,64 @@ class AvailabilitySync {
|
|||||||
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalSeasons4k = new Map([
|
// 4k
|
||||||
...filteredSeasonsMap4k,
|
const finalSeasons4k: Map<number, boolean> = new Map();
|
||||||
...plexSeasonsMap4k,
|
|
||||||
...sonarrSeasonsMap4k,
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
]);
|
plexSeasonsMap4k.forEach((value, key) => {
|
||||||
|
finalSeasons4k.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredSeasonsMap4k.forEach((value, key) => {
|
||||||
|
if (!finalSeasons4k.has(key)) {
|
||||||
|
finalSeasons4k.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||||
|
if (!finalSeasons4k.has(key)) {
|
||||||
|
finalSeasons4k.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
jellyfinSeasonsMap4k.forEach((value, key) => {
|
||||||
|
finalSeasons4k.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredSeasonsMap4k.forEach((value, key) => {
|
||||||
|
if (!finalSeasons4k.has(key)) {
|
||||||
|
finalSeasons4k.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||||
|
if (!finalSeasons4k.has(key)) {
|
||||||
|
finalSeasons4k.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Figure out how to run seasonUpdater for each season
|
||||||
|
|
||||||
if ([...finalSeasons.values()].includes(false)) {
|
if ([...finalSeasons.values()].includes(false)) {
|
||||||
await this.seasonUpdater(media, finalSeasons, false);
|
await this.seasonUpdater(
|
||||||
|
media,
|
||||||
|
finalSeasons,
|
||||||
|
false,
|
||||||
|
mediaServerType
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([...finalSeasons4k.values()].includes(false)) {
|
if ([...finalSeasons4k.values()].includes(false)) {
|
||||||
await this.seasonUpdater(media, finalSeasons4k, true);
|
await this.seasonUpdater(
|
||||||
|
media,
|
||||||
|
finalSeasons4k,
|
||||||
|
true,
|
||||||
|
mediaServerType
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -192,7 +412,7 @@ class AvailabilitySync {
|
|||||||
(media.status === MediaStatus.AVAILABLE ||
|
(media.status === MediaStatus.AVAILABLE ||
|
||||||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
) {
|
) {
|
||||||
await this.mediaUpdater(media, false);
|
await this.mediaUpdater(media, false, mediaServerType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -200,7 +420,7 @@ class AvailabilitySync {
|
|||||||
(media.status4k === MediaStatus.AVAILABLE ||
|
(media.status4k === MediaStatus.AVAILABLE ||
|
||||||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
) {
|
) {
|
||||||
await this.mediaUpdater(media, true);
|
await this.mediaUpdater(media, true, mediaServerType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +492,11 @@ class AvailabilitySync {
|
|||||||
return mediaStatus;
|
return mediaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
private async mediaUpdater(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean,
|
||||||
|
mediaServerType: MediaServerType
|
||||||
|
): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
@@ -320,17 +544,32 @@ class AvailabilitySync {
|
|||||||
mediaStatus === MediaStatus.PROCESSING
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||||
: null;
|
: null;
|
||||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
: null;
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||||
|
: null;
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||||
|
: null;
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||||
media.mediaType === 'movie' ? 'movie' : 'show'
|
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||||
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||||
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||||
} and Plex instance. Status will be changed to unknown.`,
|
} and ${
|
||||||
|
mediaServerType === MediaServerType.PLEX
|
||||||
|
? 'plex'
|
||||||
|
: mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? 'jellyfin'
|
||||||
|
: 'emby'
|
||||||
|
} instance. Status will be changed to unknown.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'AvailabilitySync' }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -358,7 +597,8 @@ class AvailabilitySync {
|
|||||||
private async seasonUpdater(
|
private async seasonUpdater(
|
||||||
media: Media,
|
media: Media,
|
||||||
seasons: Map<number, boolean>,
|
seasons: Map<number, boolean>,
|
||||||
is4k: boolean
|
is4k: boolean,
|
||||||
|
mediaServerType: MediaServerType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
@@ -370,6 +610,8 @@ class AvailabilitySync {
|
|||||||
);
|
);
|
||||||
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||||
|
|
||||||
|
// let isSeasonRemoved = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Need to check and see if there are any related season
|
// Need to check and see if there are any related season
|
||||||
// requests. If they are, we will need to delete them.
|
// requests. If they are, we will need to delete them.
|
||||||
@@ -420,7 +662,13 @@ class AvailabilitySync {
|
|||||||
media.tmdbId
|
media.tmdbId
|
||||||
}] was not found in any ${
|
}] was not found in any ${
|
||||||
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
||||||
} and Plex instance. Status will be changed to unknown.`,
|
} and ${
|
||||||
|
mediaServerType === MediaServerType.PLEX
|
||||||
|
? 'plex'
|
||||||
|
: mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? 'jellyfin'
|
||||||
|
: 'emby'
|
||||||
|
} instance. Status will be changed to unknown.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'AvailabilitySync' }
|
||||||
);
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -604,6 +852,7 @@ class AvailabilitySync {
|
|||||||
return seasonExists;
|
return seasonExists;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plex
|
||||||
private async mediaExistsInPlex(
|
private async mediaExistsInPlex(
|
||||||
media: Media,
|
media: Media,
|
||||||
is4k: boolean
|
is4k: boolean
|
||||||
@@ -719,6 +968,123 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
return seasonExistsInPlex;
|
return seasonExistsInPlex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin
|
||||||
|
private async mediaExistsInJellyfin(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map<number, boolean> }> {
|
||||||
|
const ratingKey = media.jellyfinMediaId;
|
||||||
|
const ratingKey4k = media.jellyfinMediaId4k;
|
||||||
|
let existsInJellyfin = false;
|
||||||
|
let preventSeasonSearch = false;
|
||||||
|
|
||||||
|
// Check each jellyfin instance to see if the media still exists
|
||||||
|
// If found, we will assume the media exists and prevent removal
|
||||||
|
// We can use the cache we built when we fetched the series with mediaExistsInJellyfin
|
||||||
|
try {
|
||||||
|
let jellyfinMedia: JellyfinLibraryItem | undefined;
|
||||||
|
|
||||||
|
if (ratingKey && !is4k) {
|
||||||
|
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey);
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
|
||||||
|
this.jellyfinSeasonsCache[ratingKey] =
|
||||||
|
await this.jellyfinClient?.getSeasons(ratingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k && is4k) {
|
||||||
|
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey4k);
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
|
||||||
|
this.jellyfinSeasonsCache[ratingKey4k] =
|
||||||
|
await this.jellyfinClient?.getSeasons(ratingKey4k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jellyfinMedia) {
|
||||||
|
existsInJellyfin = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404' || '500')) {
|
||||||
|
existsInJellyfin = false;
|
||||||
|
preventSeasonSearch = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||||
|
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we check each season in jellyfin for availability
|
||||||
|
// If the API returns an error other than a 404,
|
||||||
|
// we will have to prevent the season check from happening
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
const seasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
if (!preventSeasonSearch) {
|
||||||
|
const filteredSeasons = media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||||
|
season[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const season of filteredSeasons) {
|
||||||
|
const seasonExists = await this.seasonExistsInJellyfin(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonExists) {
|
||||||
|
seasonsMap.set(season.seasonNumber, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInJellyfin, seasonsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInJellyfin };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInJellyfin(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const ratingKey = media.jellyfinMediaId;
|
||||||
|
const ratingKey4k = media.jellyfinMediaId4k;
|
||||||
|
let seasonExistsInJellyfin = false;
|
||||||
|
|
||||||
|
// Check each jellyfin instance to see if the season exists
|
||||||
|
let jellyfinSeasons: JellyfinLibraryItem[] | undefined;
|
||||||
|
|
||||||
|
if (ratingKey && !is4k) {
|
||||||
|
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k && is4k) {
|
||||||
|
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonIsAvailable = jellyfinSeasons?.find(
|
||||||
|
(jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonIsAvailable) {
|
||||||
|
seasonExistsInJellyfin = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasonExistsInJellyfin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const availabilitySync = new AvailabilitySync();
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class WebhookAgent
|
|||||||
const payloadString = Buffer.from(
|
const payloadString = Buffer.from(
|
||||||
this.getSettings().options.jsonPayload,
|
this.getSettings().options.jsonPayload,
|
||||||
'base64'
|
'base64'
|
||||||
).toString('ascii');
|
).toString('utf8');
|
||||||
|
|
||||||
const parsedJSON = JSON.parse(JSON.parse(payloadString));
|
const parsedJSON = JSON.parse(JSON.parse(payloadString));
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface SyncStatus {
|
|||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class JobJellyfinSync {
|
class JellyfinScanner {
|
||||||
private sessionId: string;
|
private sessionId: string;
|
||||||
private tmdb: TheMovieDb;
|
private tmdb: TheMovieDb;
|
||||||
private jfClient: JellyfinAPI;
|
private jfClient: JellyfinAPI;
|
||||||
@@ -62,7 +62,7 @@ class JobJellyfinSync {
|
|||||||
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||||
const newMedia = new Media();
|
const newMedia = new Media();
|
||||||
|
|
||||||
if (!metadata.Id) {
|
if (!metadata?.Id) {
|
||||||
logger.debug('No Id metadata for this title. Skipping', {
|
logger.debug('No Id metadata for this title. Skipping', {
|
||||||
label: 'Plex Sync',
|
label: 'Plex Sync',
|
||||||
ratingKey: jellyfinitem.Id,
|
ratingKey: jellyfinitem.Id,
|
||||||
@@ -83,13 +83,17 @@ class JobJellyfinSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
return MediaSource.MediaStreams.filter(
|
||||||
|
(MediaStream) => MediaStream.Type === 'Video'
|
||||||
|
).some((MediaStream) => {
|
||||||
return (MediaStream.Width ?? 0) > 2000;
|
return (MediaStream.Width ?? 0) > 2000;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
|
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
return MediaSource.MediaStreams.filter(
|
||||||
|
(MediaStream) => MediaStream.Type === 'Video'
|
||||||
|
).some((MediaStream) => {
|
||||||
return (MediaStream.Width ?? 0) <= 2000;
|
return (MediaStream.Width ?? 0) <= 2000;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -168,9 +172,9 @@ class JobJellyfinSync {
|
|||||||
newMedia.jellyfinMediaId =
|
newMedia.jellyfinMediaId =
|
||||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||||
? metadata.Id
|
? metadata.Id
|
||||||
: undefined;
|
: null;
|
||||||
newMedia.jellyfinMediaId4k =
|
newMedia.jellyfinMediaId4k =
|
||||||
has4k && this.enable4kMovie ? metadata.Id : undefined;
|
has4k && this.enable4kMovie ? metadata.Id : null;
|
||||||
await mediaRepository.save(newMedia);
|
await mediaRepository.save(newMedia);
|
||||||
this.log(`Saved ${metadata.Name}`);
|
this.log(`Saved ${metadata.Name}`);
|
||||||
}
|
}
|
||||||
@@ -197,6 +201,14 @@ class JobJellyfinSync {
|
|||||||
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id;
|
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id;
|
||||||
const metadata = await this.jfClient.getItemData(Id);
|
const metadata = await this.jfClient.getItemData(Id);
|
||||||
|
|
||||||
|
if (!metadata?.Id) {
|
||||||
|
logger.debug('No Id metadata for this title. Skipping', {
|
||||||
|
label: 'Plex Sync',
|
||||||
|
ratingKey: jellyfinitem.Id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata.ProviderIds.Tvdb) {
|
if (metadata.ProviderIds.Tvdb) {
|
||||||
tvShow = await this.tmdb.getShowByTvdbId({
|
tvShow = await this.tmdb.getShowByTvdbId({
|
||||||
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
||||||
@@ -275,7 +287,7 @@ class JobJellyfinSync {
|
|||||||
episode.Id
|
episode.Id
|
||||||
);
|
);
|
||||||
|
|
||||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||||
if (MediaStream.Type === 'Video') {
|
if (MediaStream.Type === 'Video') {
|
||||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||||
@@ -453,8 +465,9 @@ class JobJellyfinSync {
|
|||||||
tmdbId: tvShow.id,
|
tmdbId: tvShow.id,
|
||||||
tvdbId: tvShow.external_ids.tvdb_id,
|
tvdbId: tvShow.external_ids.tvdb_id,
|
||||||
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
|
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
|
||||||
jellyfinMediaId: Id,
|
jellyfinMediaId: isAllStandardSeasons ? Id : null,
|
||||||
jellyfinMediaId4k: Id,
|
jellyfinMediaId4k:
|
||||||
|
isAll4kSeasons && this.enable4kShow ? Id : null,
|
||||||
status: isAllStandardSeasons
|
status: isAllStandardSeasons
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: newSeasons.some(
|
: newSeasons.some(
|
||||||
@@ -675,7 +688,7 @@ class JobJellyfinSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jobJellyfinFullSync = new JobJellyfinSync();
|
export const jellyfinFullScanner = new JellyfinScanner();
|
||||||
export const jobJellyfinRecentSync = new JobJellyfinSync({
|
export const jellyfinRecentScanner = new JellyfinScanner({
|
||||||
isRecentOnly: true,
|
isRecentOnly: true,
|
||||||
});
|
});
|
||||||
@@ -40,6 +40,7 @@ export interface JellyfinSettings {
|
|||||||
name: string;
|
name: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
externalHostname?: string;
|
externalHostname?: string;
|
||||||
|
jellyfinForgotPasswordUrl?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
@@ -131,6 +132,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
jellyfinHost?: string;
|
jellyfinHost?: string;
|
||||||
jellyfinExternalHost?: string;
|
jellyfinExternalHost?: string;
|
||||||
|
jellyfinForgotPasswordUrl?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
cacheImages: boolean;
|
cacheImages: boolean;
|
||||||
@@ -331,6 +333,7 @@ class Settings {
|
|||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
externalHostname: '',
|
externalHostname: '',
|
||||||
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
},
|
},
|
||||||
@@ -534,6 +537,7 @@ class Settings {
|
|||||||
applicationUrl: this.data.main.applicationUrl,
|
applicationUrl: this.data.main.applicationUrl,
|
||||||
hideAvailable: this.data.main.hideAvailable,
|
hideAvailable: this.data.main.hideAvailable,
|
||||||
localLogin: this.data.main.localLogin,
|
localLogin: this.data.main.localLogin,
|
||||||
|
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
||||||
movie4kEnabled: this.data.radarr.some(
|
movie4kEnabled: this.data.radarr.some(
|
||||||
(radarr) => radarr.is4k && radarr.isDefault
|
(radarr) => radarr.is4k && radarr.isDefault
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import JellyfinAPI from '@server/api/jellyfin';
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
@@ -9,8 +10,11 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { ApiError } from '@server/types/error';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import gravatarUrl from 'gravatar-url';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
const authRoutes = Router();
|
const authRoutes = Router();
|
||||||
|
|
||||||
@@ -268,30 +272,112 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
? jellyfinHost.slice(0, -1)
|
? jellyfinHost.slice(0, -1)
|
||||||
: jellyfinHost;
|
: jellyfinHost;
|
||||||
|
|
||||||
const account = await jellyfinserver.login(body.username, body.password);
|
const ip = req.ip;
|
||||||
|
let clientIp;
|
||||||
|
|
||||||
|
if (ip) {
|
||||||
|
if (net.isIPv4(ip)) {
|
||||||
|
clientIp = ip;
|
||||||
|
} else if (net.isIPv6(ip)) {
|
||||||
|
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await jellyfinserver.login(
|
||||||
|
body.username,
|
||||||
|
body.password,
|
||||||
|
clientIp
|
||||||
|
);
|
||||||
|
|
||||||
// Next let's see if the user already exists
|
// Next let's see if the user already exists
|
||||||
user = await userRepository.findOne({
|
user = await userRepository.findOne({
|
||||||
where: { jellyfinUserId: account.User.Id },
|
where: { jellyfinUserId: account.User.Id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (!user && !(await userRepository.count())) {
|
||||||
|
// Check if user is admin on jellyfin
|
||||||
|
if (account.User.Policy.IsAdministrator === false) {
|
||||||
|
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
|
||||||
|
{
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
jellyfinUsername: account.User.Name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// User doesn't exist, and there are no users in the database, we'll create the user
|
||||||
|
// with admin permission
|
||||||
|
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||||
|
user = new User({
|
||||||
|
email: body.email,
|
||||||
|
jellyfinUsername: account.User.Name,
|
||||||
|
jellyfinUserId: account.User.Id,
|
||||||
|
jellyfinDeviceId: deviceId,
|
||||||
|
jellyfinAuthToken: account.AccessToken,
|
||||||
|
permissions: Permission.ADMIN,
|
||||||
|
avatar: account.User.PrimaryImageTag
|
||||||
|
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
|
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
|
||||||
|
userType: UserType.JELLYFIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverName = await jellyfinserver.getServerName();
|
||||||
|
|
||||||
|
settings.jellyfin.name = serverName;
|
||||||
|
settings.jellyfin.hostname = body.hostname ?? '';
|
||||||
|
settings.jellyfin.serverId = account.User.ServerId;
|
||||||
|
settings.save();
|
||||||
|
startJobs();
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
|
// User already exists, let's update their information
|
||||||
|
else if (account.User.Id === user?.jellyfinUserId) {
|
||||||
|
logger.info(
|
||||||
|
`Found matching ${
|
||||||
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? 'Jellyfin'
|
||||||
|
: 'Emby'
|
||||||
|
} user; updating user with ${
|
||||||
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? 'Jellyfin'
|
||||||
|
: 'Emby'
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
jellyfinUsername: account.User.Name,
|
||||||
|
}
|
||||||
|
);
|
||||||
// Let's check if their authtoken is up to date
|
// Let's check if their authtoken is up to date
|
||||||
if (user.jellyfinAuthToken !== account.AccessToken) {
|
if (user.jellyfinAuthToken !== account.AccessToken) {
|
||||||
user.jellyfinAuthToken = account.AccessToken;
|
user.jellyfinAuthToken = account.AccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
||||||
if (account.User.PrimaryImageTag) {
|
if (account.User.PrimaryImageTag) {
|
||||||
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||||
} else {
|
} else {
|
||||||
user.avatar = '/os_logo_square.png';
|
user.avatar = gravatarUrl(user.email, {
|
||||||
|
default: 'mm',
|
||||||
|
size: 200,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
user.jellyfinUsername = account.User.Name;
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
|
||||||
if (user.username === account.User.Name) {
|
if (user.username === account.User.Name) {
|
||||||
user.username = '';
|
user.username = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
|
||||||
|
// if (process.env.JELLYFIN_TYPE === 'emby') {
|
||||||
|
// settings.main.mediaServerType = MediaServerType.EMBY;
|
||||||
|
// settings.save();
|
||||||
|
// }
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
} else if (!settings.main.newPlexLogin) {
|
} else if (!settings.main.newPlexLogin) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -307,69 +393,38 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
status: 403,
|
status: 403,
|
||||||
message: 'Access denied.',
|
message: 'Access denied.',
|
||||||
});
|
});
|
||||||
} else {
|
} else if (!user) {
|
||||||
// Here we check if it's the first user. If it is, we create the user with no check
|
logger.info(
|
||||||
// and give them admin permissions
|
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
|
||||||
const totalUsers = await userRepository.count();
|
{
|
||||||
if (totalUsers === 0) {
|
label: 'API',
|
||||||
logger.info(
|
ip: req.ip,
|
||||||
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
|
|
||||||
{
|
|
||||||
label: 'API',
|
|
||||||
ip: req.ip,
|
|
||||||
jellyfinUsername: account.User.Name,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
user = new User({
|
|
||||||
email: body.email,
|
|
||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
jellyfinUserId: account.User.Id,
|
|
||||||
jellyfinDeviceId: deviceId,
|
|
||||||
jellyfinAuthToken: account.AccessToken,
|
|
||||||
permissions: Permission.ADMIN,
|
|
||||||
avatar: account.User.PrimaryImageTag
|
|
||||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
|
||||||
: '/os_logo_square.png',
|
|
||||||
userType: UserType.JELLYFIN,
|
|
||||||
});
|
|
||||||
await userRepository.save(user);
|
|
||||||
|
|
||||||
//Update hostname in settings if it doesn't exist (initial configuration)
|
|
||||||
//Also set mediaservertype to JELLYFIN
|
|
||||||
if (settings.jellyfin.hostname === '') {
|
|
||||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
|
||||||
settings.jellyfin.hostname = body.hostname ?? '';
|
|
||||||
settings.jellyfin.serverId = account.User.ServerId;
|
|
||||||
settings.save();
|
|
||||||
startJobs();
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!body.email) {
|
||||||
|
throw new Error('add_email');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
user = new User({
|
||||||
if (!body.email) {
|
email: body.email,
|
||||||
throw new Error('add_email');
|
jellyfinUsername: account.User.Name,
|
||||||
}
|
jellyfinUserId: account.User.Id,
|
||||||
|
jellyfinDeviceId: deviceId,
|
||||||
user = new User({
|
jellyfinAuthToken: account.AccessToken,
|
||||||
email: body.email,
|
permissions: settings.main.defaultPermissions,
|
||||||
jellyfinUsername: account.User.Name,
|
avatar: account.User.PrimaryImageTag
|
||||||
jellyfinUserId: account.User.Id,
|
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
jellyfinDeviceId: deviceId,
|
: gravatarUrl(body.email, { default: 'mm', size: 200 }),
|
||||||
jellyfinAuthToken: account.AccessToken,
|
userType: UserType.JELLYFIN,
|
||||||
permissions: settings.main.defaultPermissions,
|
});
|
||||||
avatar: account.User.PrimaryImageTag
|
//initialize Jellyfin/Emby users with local login
|
||||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
: '/os_logo_square.png',
|
if (passedExplicitPassword) {
|
||||||
userType: UserType.JELLYFIN,
|
await user.setPassword(body.password ?? '');
|
||||||
});
|
|
||||||
//initialize Jellyfin/Emby users with local login
|
|
||||||
const passedExplicitPassword =
|
|
||||||
body.password && body.password.length > 0;
|
|
||||||
if (passedExplicitPassword) {
|
|
||||||
await user.setPassword(body.password ?? '');
|
|
||||||
}
|
|
||||||
await userRepository.save(user);
|
|
||||||
}
|
}
|
||||||
|
await userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set logged in session
|
// Set logged in session
|
||||||
@@ -379,33 +434,63 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
|
|
||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === 'Unauthorized') {
|
switch (e.errorCode) {
|
||||||
logger.warn(
|
case ApiErrorCode.InvalidUrl:
|
||||||
'Failed login attempt from user with incorrect Jellyfin credentials',
|
logger.error(
|
||||||
{
|
`The provided ${
|
||||||
label: 'Auth',
|
process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin'
|
||||||
account: {
|
} is invalid or the server is not reachable.`,
|
||||||
ip: req.ip,
|
{
|
||||||
email: body.username,
|
label: 'Auth',
|
||||||
password: '__REDACTED__',
|
error: e.errorCode,
|
||||||
},
|
status: e.statusCode,
|
||||||
}
|
hostname: body.hostname,
|
||||||
);
|
}
|
||||||
return next({
|
);
|
||||||
status: 401,
|
return next({
|
||||||
message: 'Unauthorized',
|
status: e.statusCode,
|
||||||
});
|
message: e.errorCode,
|
||||||
} else if (e.message === 'add_email') {
|
});
|
||||||
return next({
|
|
||||||
status: 406,
|
case ApiErrorCode.InvalidCredentials:
|
||||||
message: 'CREDENTIAL_ERROR_ADD_EMAIL',
|
logger.warn(
|
||||||
});
|
'Failed login attempt from user with incorrect Jellyfin credentials',
|
||||||
} else {
|
{
|
||||||
logger.error(e.message, { label: 'Auth' });
|
label: 'Auth',
|
||||||
return next({
|
account: {
|
||||||
status: 500,
|
ip: req.ip,
|
||||||
message: 'Something went wrong.',
|
email: body.username,
|
||||||
});
|
password: '__REDACTED__',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return next({
|
||||||
|
status: e.statusCode,
|
||||||
|
message: e.errorCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
case ApiErrorCode.NotAdmin:
|
||||||
|
logger.warn(
|
||||||
|
'Failed login attempt from user without admin permissions',
|
||||||
|
{
|
||||||
|
label: 'Auth',
|
||||||
|
account: {
|
||||||
|
ip: req.ip,
|
||||||
|
email: body.username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return next({
|
||||||
|
status: e.statusCode,
|
||||||
|
message: e.errorCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.error(e.message, { label: 'Auth' });
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import type {
|
|||||||
LogsResultsResponse,
|
LogsResultsResponse,
|
||||||
SettingsAboutResponse,
|
SettingsAboutResponse,
|
||||||
} from '@server/interfaces/api/settingsInterfaces';
|
} from '@server/interfaces/api/settingsInterfaces';
|
||||||
import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
|
|
||||||
import { scheduledJobs } from '@server/job/schedule';
|
import { scheduledJobs } from '@server/job/schedule';
|
||||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
|
||||||
import { plexFullScanner } from '@server/lib/scanners/plex';
|
import { plexFullScanner } from '@server/lib/scanners/plex';
|
||||||
import type { JobId, Library, MainSettings } from '@server/lib/settings';
|
import type { JobId, Library, MainSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
@@ -29,6 +29,7 @@ import { getAppVersion } from '@server/utils/appVersion';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import gravatarUrl from 'gravatar-url';
|
||||||
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
||||||
import { rescheduleJob } from 'node-schedule';
|
import { rescheduleJob } from 'node-schedule';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -260,7 +261,7 @@ settingsRoutes.post('/jellyfin', (req, res) => {
|
|||||||
return res.status(200).json(settings.jellyfin);
|
return res.status(200).json(settings.jellyfin);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
if (req.query.sync) {
|
if (req.query.sync) {
|
||||||
@@ -280,6 +281,19 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
|||||||
|
|
||||||
const libraries = await jellyfinClient.getLibraries();
|
const libraries = await jellyfinClient.getLibraries();
|
||||||
|
|
||||||
|
if (libraries.length === 0) {
|
||||||
|
// Check if no libraries are found due to the fallback to user views
|
||||||
|
// This only affects LDAP users
|
||||||
|
const account = await jellyfinClient.getUser();
|
||||||
|
|
||||||
|
// Automatic Library grouping is not supported when user views are used to get library
|
||||||
|
if (account.Configuration.GroupedFolders.length > 0) {
|
||||||
|
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' });
|
||||||
|
}
|
||||||
|
|
||||||
const newLibraries: Library[] = libraries.map((library) => {
|
const newLibraries: Library[] = libraries.map((library) => {
|
||||||
const existing = settings.jellyfin.libraries.find(
|
const existing = settings.jellyfin.libraries.find(
|
||||||
(l) => l.id === library.key && l.name === library.title
|
(l) => l.id === library.key && l.name === library.title
|
||||||
@@ -337,7 +351,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
id: user.Id,
|
id: user.Id,
|
||||||
thumb: user.PrimaryImageTag
|
thumb: user.PrimaryImageTag
|
||||||
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
|
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
|
||||||
: '/os_logo_square.png',
|
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
|
||||||
email: user.Name,
|
email: user.Name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -345,16 +359,16 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
|
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
|
||||||
return res.status(200).json(jobJellyfinFullSync.status());
|
return res.status(200).json(jellyfinFullScanner.status());
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/jellyfin/sync', (req, res) => {
|
settingsRoutes.post('/jellyfin/sync', (req, res) => {
|
||||||
if (req.body.cancel) {
|
if (req.body.cancel) {
|
||||||
jobJellyfinFullSync.cancel();
|
jellyfinFullScanner.cancel();
|
||||||
} else if (req.body.start) {
|
} else if (req.body.start) {
|
||||||
jobJellyfinFullSync.run();
|
jellyfinFullScanner.run();
|
||||||
}
|
}
|
||||||
return res.status(200).json(jobJellyfinFullSync.status());
|
return res.status(200).json(jellyfinFullScanner.status());
|
||||||
});
|
});
|
||||||
settingsRoutes.get('/tautulli', (_req, res) => {
|
settingsRoutes.get('/tautulli', (_req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
|||||||
...webhookSettings.options,
|
...webhookSettings.options,
|
||||||
jsonPayload: JSON.parse(
|
jsonPayload: JSON.parse(
|
||||||
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
|
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
|
||||||
'ascii'
|
'utf8'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -537,7 +537,10 @@ router.post(
|
|||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
avatar: jellyfinUser?.PrimaryImageTag
|
avatar: jellyfinUser?.PrimaryImageTag
|
||||||
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
||||||
: '/os_logo_square.png',
|
: gravatarUrl(jellyfinUser?.Name ?? '', {
|
||||||
|
default: 'mm',
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
|
user.email = req.body.email ?? user.email;
|
||||||
|
|
||||||
// Update quota values only if the user has the correct permissions
|
// Update quota values only if the user has the correct permissions
|
||||||
if (
|
if (
|
||||||
@@ -127,20 +128,19 @@ userSettingsRoutes.post<
|
|||||||
user.settings.originalLanguage = req.body.originalLanguage;
|
user.settings.originalLanguage = req.body.originalLanguage;
|
||||||
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
|
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
|
||||||
user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
|
user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
|
||||||
user.email = req.body.email ?? user.email;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await userRepository.save(user);
|
const savedUser = await userRepository.save(user);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
username: user.username,
|
username: savedUser.username,
|
||||||
discordId: user.settings.discordId,
|
discordId: savedUser.settings?.discordId,
|
||||||
locale: user.settings.locale,
|
locale: savedUser.settings?.locale,
|
||||||
region: user.settings.region,
|
region: savedUser.settings?.region,
|
||||||
originalLanguage: user.settings.originalLanguage,
|
originalLanguage: savedUser.settings?.originalLanguage,
|
||||||
watchlistSyncMovies: user.settings.watchlistSyncMovies,
|
watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies,
|
||||||
watchlistSyncTv: user.settings.watchlistSyncTv,
|
watchlistSyncTv: savedUser.settings?.watchlistSyncTv,
|
||||||
email: user.email,
|
email: savedUser.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
|
|||||||
9
server/types/error.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ApiErrorCode } from '@server/constants/error';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public statusCode: number, public errorCode: ApiErrorCode) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = 'apiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/assets/services/letterboxd.svg
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
@@ -19,6 +19,7 @@ type ListViewProps = {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isReachingEnd?: boolean;
|
isReachingEnd?: boolean;
|
||||||
onScrollBottom: () => void;
|
onScrollBottom: () => void;
|
||||||
|
mutateParent?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListView = ({
|
const ListView = ({
|
||||||
@@ -28,6 +29,7 @@ const ListView = ({
|
|||||||
onScrollBottom,
|
onScrollBottom,
|
||||||
isReachingEnd,
|
isReachingEnd,
|
||||||
plexItems,
|
plexItems,
|
||||||
|
mutateParent,
|
||||||
}: ListViewProps) => {
|
}: ListViewProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
||||||
@@ -48,6 +50,7 @@ const ListView = ({
|
|||||||
type={title.mediaType}
|
type={title.mediaType}
|
||||||
isAddedToWatchlist={true}
|
isAddedToWatchlist={true}
|
||||||
canExpand
|
canExpand
|
||||||
|
mutateParent={mutateParent}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const DiscoverWatchlist = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
error,
|
||||||
|
mutate,
|
||||||
} = useDiscover<WatchlistItem>(
|
} = useDiscover<WatchlistItem>(
|
||||||
`/api/v1/${
|
`/api/v1/${
|
||||||
router.pathname.startsWith('/profile')
|
router.pathname.startsWith('/profile')
|
||||||
@@ -76,6 +77,7 @@ const DiscoverWatchlist = () => {
|
|||||||
}
|
}
|
||||||
isReachingEnd={isReachingEnd}
|
isReachingEnd={isReachingEnd}
|
||||||
onScrollBottom={fetchMore}
|
onScrollBottom={fetchMore}
|
||||||
|
mutateParent={mutate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import EmbyLogo from '@app/assets/services/emby.svg';
|
import EmbyLogo from '@app/assets/services/emby.svg';
|
||||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||||
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
|
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
|
||||||
|
import LetterboxdLogo from '@app/assets/services/letterboxd.svg';
|
||||||
import PlexLogo from '@app/assets/services/plex.svg';
|
import PlexLogo from '@app/assets/services/plex.svg';
|
||||||
import RTLogo from '@app/assets/services/rt.svg';
|
import RTLogo from '@app/assets/services/rt.svg';
|
||||||
import TmdbLogo from '@app/assets/services/tmdb.svg';
|
import TmdbLogo from '@app/assets/services/tmdb.svg';
|
||||||
@@ -103,6 +104,16 @@ const ExternalLinkBlock = ({
|
|||||||
<TraktLogo />
|
<TraktLogo />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{tmdbId && mediaType === MediaType.MOVIE && (
|
||||||
|
<a
|
||||||
|
href={`https://letterboxd.com/tmdb/${tmdbId}`}
|
||||||
|
className="w-8 opacity-50 transition duration-300 hover:opacity-100"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<LetterboxdLogo />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
|
|||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
@@ -24,7 +25,9 @@ const messages = defineMessages({
|
|||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
loginerror: 'Something went wrong while trying to sign in.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
|
adminerror: 'You must use an admin account to sign in.',
|
||||||
credentialerror: 'The username or password is incorrect.',
|
credentialerror: 'The username or password is incorrect.',
|
||||||
|
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||||
signingin: 'Signing in…',
|
signingin: 'Signing in…',
|
||||||
signin: 'Sign In',
|
signin: 'Sign In',
|
||||||
initialsigningin: 'Connecting…',
|
initialsigningin: 'Connecting…',
|
||||||
@@ -90,12 +93,24 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorMessage = null;
|
||||||
|
switch (e.response?.data?.message) {
|
||||||
|
case ApiErrorCode.InvalidUrl:
|
||||||
|
errorMessage = messages.invalidurlerror;
|
||||||
|
break;
|
||||||
|
case ApiErrorCode.InvalidCredentials:
|
||||||
|
errorMessage = messages.credentialerror;
|
||||||
|
break;
|
||||||
|
case ApiErrorCode.NotAdmin:
|
||||||
|
errorMessage = messages.adminerror;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = messages.loginerror;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||||
e.message == 'Request failed with status code 401'
|
|
||||||
? messages.credentialerror
|
|
||||||
: messages.loginerror
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
@@ -222,6 +237,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||||
? settings.currentSettings.jellyfinExternalHost
|
? settings.currentSettings.jellyfinExternalHost
|
||||||
: settings.currentSettings.jellyfinHost;
|
: settings.currentSettings.jellyfinHost;
|
||||||
|
const jellyfinForgotPasswordUrl =
|
||||||
|
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Formik
|
<Formik
|
||||||
@@ -298,11 +315,15 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
buttonType="ghost"
|
buttonType="ghost"
|
||||||
href={`${baseUrl}/web/index.html#!/${
|
href={
|
||||||
process.env.JELLYFIN_TYPE === 'emby'
|
jellyfinForgotPasswordUrl
|
||||||
? 'startup/'
|
? `${jellyfinForgotPasswordUrl}`
|
||||||
: ''
|
: `${baseUrl}/web/index.html#!/${
|
||||||
}forgotpassword.html`}
|
process.env.JELLYFIN_TYPE === 'emby'
|
||||||
|
? 'startup/'
|
||||||
|
: ''
|
||||||
|
}forgotpassword.html`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.forgotpassword)}
|
{intl.formatMessage(messages.forgotpassword)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -434,33 +434,38 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
|
{hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||||
<Tooltip content={intl.formatMessage(messages.managemovie)}>
|
data.mediaInfo &&
|
||||||
<Button
|
(data.mediaInfo.jellyfinMediaId ||
|
||||||
buttonType="ghost"
|
data.mediaInfo.jellyfinMediaId4k ||
|
||||||
onClick={() => setShowManager(true)}
|
data.mediaInfo.status !== MediaStatus.UNKNOWN ||
|
||||||
className="relative ml-2 first:ml-0"
|
data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && (
|
||||||
>
|
<Tooltip content={intl.formatMessage(messages.managemovie)}>
|
||||||
<CogIcon className="!mr-0" />
|
<Button
|
||||||
{hasPermission(
|
buttonType="ghost"
|
||||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
onClick={() => setShowManager(true)}
|
||||||
{
|
className="relative ml-2 first:ml-0"
|
||||||
type: 'or',
|
>
|
||||||
}
|
<CogIcon className="!mr-0" />
|
||||||
) &&
|
{hasPermission(
|
||||||
(
|
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||||
data.mediaInfo?.issues.filter(
|
{
|
||||||
(issue) => issue.status === IssueStatus.OPEN
|
type: 'or',
|
||||||
) ?? []
|
}
|
||||||
).length > 0 && (
|
) &&
|
||||||
<>
|
(
|
||||||
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
|
data.mediaInfo?.issues.filter(
|
||||||
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
|
(issue) => issue.status === IssueStatus.OPEN
|
||||||
</>
|
) ?? []
|
||||||
)}
|
).length > 0 && (
|
||||||
</Button>
|
<>
|
||||||
</Tooltip>
|
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
|
||||||
)}
|
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-overview">
|
<div className="media-overview">
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ const messages = defineMessages({
|
|||||||
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
||||||
jellyfinSettings: '{mediaServerName} Settings',
|
jellyfinSettings: '{mediaServerName} Settings',
|
||||||
jellyfinSettingsDescription:
|
jellyfinSettingsDescription:
|
||||||
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL.',
|
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
|
||||||
externalUrl: 'External URL',
|
externalUrl: 'External URL',
|
||||||
internalUrl: 'Internal URL',
|
internalUrl: 'Internal URL',
|
||||||
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
||||||
|
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
||||||
|
jellyfinSyncFailedAutomaticGroupedFolders:
|
||||||
|
'Custom authentication with Automatic Library Grouping not supported',
|
||||||
|
jellyfinSyncFailedGenericError:
|
||||||
|
'Something went wrong while syncing libraries',
|
||||||
validationUrl: 'You must provide a valid URL',
|
validationUrl: 'You must provide a valid URL',
|
||||||
syncing: 'Syncing',
|
syncing: 'Syncing',
|
||||||
syncJellyfin: 'Sync Libraries',
|
syncJellyfin: 'Sync Libraries',
|
||||||
@@ -69,6 +75,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
showAdvancedSettings,
|
showAdvancedSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const toasts = useToasts();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -94,6 +101,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||||
intl.formatMessage(messages.validationUrl)
|
intl.formatMessage(messages.validationUrl)
|
||||||
),
|
),
|
||||||
|
jellyfinForgotPasswordUrl: Yup.string().matches(
|
||||||
|
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||||
|
intl.formatMessage(messages.validationUrl)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeLibraries =
|
const activeLibraries =
|
||||||
@@ -112,11 +123,43 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
params.enable = activeLibraries.join(',');
|
params.enable = activeLibraries.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.get('/api/v1/settings/jellyfin/library', {
|
try {
|
||||||
params,
|
await axios.get('/api/v1/settings/jellyfin/library', {
|
||||||
});
|
params,
|
||||||
setIsSyncing(false);
|
});
|
||||||
revalidate();
|
setIsSyncing(false);
|
||||||
|
revalidate();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
||||||
|
toasts.addToast(
|
||||||
|
intl.formatMessage(
|
||||||
|
messages.jellyfinSyncFailedAutomaticGroupedFolders
|
||||||
|
),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'warning',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
||||||
|
toasts.addToast(
|
||||||
|
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toasts.addToast(
|
||||||
|
intl.formatMessage(messages.jellyfinSyncFailedGenericError),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIsSyncing(false);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startScan = async () => {
|
const startScan = async () => {
|
||||||
@@ -353,6 +396,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
jellyfinInternalUrl: data?.hostname || '',
|
jellyfinInternalUrl: data?.hostname || '',
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
}}
|
}}
|
||||||
validationSchema={JellyfinSettingsSchema}
|
validationSchema={JellyfinSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -360,6 +404,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
await axios.post('/api/v1/settings/jellyfin', {
|
await axios.post('/api/v1/settings/jellyfin', {
|
||||||
hostname: values.jellyfinInternalUrl,
|
hostname: values.jellyfinInternalUrl,
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
} as JellyfinSettings);
|
} as JellyfinSettings);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
@@ -437,6 +482,30 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="jellyfinForgotPasswordUrl"
|
||||||
|
className="text-label"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="jellyfinForgotPasswordUrl"
|
||||||
|
name="jellyfinForgotPasswordUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.jellyfinForgotPasswordUrl &&
|
||||||
|
touched.jellyfinForgotPasswordUrl && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.jellyfinForgotPasswordUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TmdbTitleCardProps {
|
|||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
isAddedToWatchlist?: boolean;
|
isAddedToWatchlist?: boolean;
|
||||||
|
mutateParent?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -25,6 +26,7 @@ const TmdbTitleCard = ({
|
|||||||
type,
|
type,
|
||||||
canExpand,
|
canExpand,
|
||||||
isAddedToWatchlist = false,
|
isAddedToWatchlist = false,
|
||||||
|
mutateParent,
|
||||||
}: TmdbTitleCardProps) => {
|
}: TmdbTitleCardProps) => {
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ const TmdbTitleCard = ({
|
|||||||
year={title.releaseDate}
|
year={title.releaseDate}
|
||||||
mediaType={'movie'}
|
mediaType={'movie'}
|
||||||
canExpand={canExpand}
|
canExpand={canExpand}
|
||||||
|
mutateParent={mutateParent}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TitleCard
|
<TitleCard
|
||||||
@@ -87,6 +90,7 @@ const TmdbTitleCard = ({
|
|||||||
year={title.firstAirDate}
|
year={title.firstAirDate}
|
||||||
mediaType={'tv'}
|
mediaType={'tv'}
|
||||||
canExpand={canExpand}
|
canExpand={canExpand}
|
||||||
|
mutateParent={mutateParent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface TitleCardProps {
|
|||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
inProgress?: boolean;
|
inProgress?: boolean;
|
||||||
isAddedToWatchlist?: number | boolean;
|
isAddedToWatchlist?: number | boolean;
|
||||||
|
mutateParent?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -61,6 +62,7 @@ const TitleCard = ({
|
|||||||
isAddedToWatchlist = false,
|
isAddedToWatchlist = false,
|
||||||
inProgress = false,
|
inProgress = false,
|
||||||
canExpand = false,
|
canExpand = false,
|
||||||
|
mutateParent,
|
||||||
}: TitleCardProps) => {
|
}: TitleCardProps) => {
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -148,6 +150,9 @@ const TitleCard = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
mutate('/api/v1/discover/watchlist');
|
mutate('/api/v1/discover/watchlist');
|
||||||
|
if (mutateParent) {
|
||||||
|
mutateParent();
|
||||||
|
}
|
||||||
setToggleWatchlist((prevState) => !prevState);
|
setToggleWatchlist((prevState) => !prevState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ const messages = defineMessages({
|
|||||||
discordId: 'Discord User ID',
|
discordId: 'Discord User ID',
|
||||||
discordIdTip:
|
discordIdTip:
|
||||||
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account',
|
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account',
|
||||||
|
validationemailrequired: 'Email required',
|
||||||
|
validationemailformat: 'Valid email required',
|
||||||
validationDiscordId: 'You must provide a valid Discord user ID',
|
validationDiscordId: 'You must provide a valid Discord user ID',
|
||||||
plexwatchlistsyncmovies: 'Auto-Request Movies',
|
plexwatchlistsyncmovies: 'Auto-Request Movies',
|
||||||
plexwatchlistsyncmoviestip:
|
plexwatchlistsyncmoviestip:
|
||||||
@@ -88,6 +90,9 @@ const UserGeneralSettings = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const UserGeneralSettingsSchema = Yup.object().shape({
|
const UserGeneralSettingsSchema = Yup.object().shape({
|
||||||
|
email: Yup.string()
|
||||||
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
|
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||||
discordId: Yup.string()
|
discordId: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
|
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type AvailableLocale =
|
|||||||
| 'en'
|
| 'en'
|
||||||
| 'el'
|
| 'el'
|
||||||
| 'es'
|
| 'es'
|
||||||
|
| 'es-MX'
|
||||||
| 'fr'
|
| 'fr'
|
||||||
| 'hr'
|
| 'hr'
|
||||||
| 'hu'
|
| 'hu'
|
||||||
@@ -59,6 +60,10 @@ export const availableLanguages: AvailableLanguageObject = {
|
|||||||
code: 'es',
|
code: 'es',
|
||||||
display: 'Español',
|
display: 'Español',
|
||||||
},
|
},
|
||||||
|
'es-MX': {
|
||||||
|
code: 'es-MX',
|
||||||
|
display: 'Español (Latinoamérica)',
|
||||||
|
},
|
||||||
fr: {
|
fr: {
|
||||||
code: 'fr',
|
code: 'fr',
|
||||||
display: 'Français',
|
display: 'Français',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface DiscoverResult<T, S> {
|
|||||||
error: unknown;
|
error: unknown;
|
||||||
titles: T[];
|
titles: T[];
|
||||||
firstResultData?: BaseSearchResult<T> & S;
|
firstResultData?: BaseSearchResult<T> & S;
|
||||||
|
mutate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraEncodes: [RegExp, string][] = [
|
const extraEncodes: [RegExp, string][] = [
|
||||||
@@ -54,7 +55,7 @@ const useDiscover = <
|
|||||||
{ hideAvailable = true } = {}
|
{ hideAvailable = true } = {}
|
||||||
): DiscoverResult<T, S> => {
|
): DiscoverResult<T, S> => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { data, error, size, setSize, isValidating } = useSWRInfinite<
|
const { data, error, size, setSize, isValidating, mutate } = useSWRInfinite<
|
||||||
BaseSearchResult<T> & S
|
BaseSearchResult<T> & S
|
||||||
>(
|
>(
|
||||||
(pageIndex: number, previousPageData) => {
|
(pageIndex: number, previousPageData) => {
|
||||||
@@ -119,6 +120,7 @@ const useDiscover = <
|
|||||||
error,
|
error,
|
||||||
titles,
|
titles,
|
||||||
firstResultData: data?.[0],
|
firstResultData: data?.[0],
|
||||||
|
mutate,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
"components.TvDetails.overview": "Přehled",
|
"components.TvDetails.overview": "Přehled",
|
||||||
"components.TvDetails.cast": "Obsazení",
|
"components.TvDetails.cast": "Obsazení",
|
||||||
"components.TvDetails.anime": "Anime",
|
"components.TvDetails.anime": "Anime",
|
||||||
"components.StatusChacker.reloadJellyseerr": "Znovu načíst",
|
"components.StatusChacker.reloadOverseerr": "Znovu načíst",
|
||||||
"components.Setup.tip": "Tip",
|
"components.Setup.tip": "Tip",
|
||||||
"components.Setup.setup": "Konfigurace",
|
"components.Setup.setup": "Konfigurace",
|
||||||
"components.Setup.finishing": "Dokončování…",
|
"components.Setup.finishing": "Dokončování…",
|
||||||
|
|||||||
@@ -724,7 +724,7 @@
|
|||||||
"components.StatusBadge.status4k": "4K {status}",
|
"components.StatusBadge.status4k": "4K {status}",
|
||||||
"components.Setup.tip": "Tip",
|
"components.Setup.tip": "Tip",
|
||||||
"components.Setup.welcome": "Velkommen til Jellyseerr",
|
"components.Setup.welcome": "Velkommen til Jellyseerr",
|
||||||
"components.StatusChacker.reloadJellyseerr": "Genindlæs",
|
"components.StatusChacker.reloadOverseerr": "Genindlæs",
|
||||||
"components.TvDetails.anime": "Anime",
|
"components.TvDetails.anime": "Anime",
|
||||||
"components.TvDetails.cast": "Roller",
|
"components.TvDetails.cast": "Roller",
|
||||||
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minutter",
|
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minutter",
|
||||||
|
|||||||
@@ -634,7 +634,7 @@
|
|||||||
"components.TvDetails.anime": "Anime",
|
"components.TvDetails.anime": "Anime",
|
||||||
"components.TvDetails.TvCrew.fullseriescrew": "Όλο το Πλήρωμα της Σειράς",
|
"components.TvDetails.TvCrew.fullseriescrew": "Όλο το Πλήρωμα της Σειράς",
|
||||||
"components.TvDetails.TvCast.fullseriescast": "Όλοι οι Ηθοποιοί της Σειράς",
|
"components.TvDetails.TvCast.fullseriescast": "Όλοι οι Ηθοποιοί της Σειράς",
|
||||||
"components.StatusChacker.reloadJellyseerr": "Επαναφόρτωση",
|
"components.StatusChacker.reloadOverseerr": "Επαναφόρτωση",
|
||||||
"components.StatusChacker.newversionavailable": "Ενημέρωση εφαρμογής",
|
"components.StatusChacker.newversionavailable": "Ενημέρωση εφαρμογής",
|
||||||
"components.StatusChacker.newversionDescription": "Το Jellyseerr έχει ενημερωθεί! Κάνε κλικ στο παρακάτω κουμπί για να φορτώσει ξανά η σελίδα.",
|
"components.StatusChacker.newversionDescription": "Το Jellyseerr έχει ενημερωθεί! Κάνε κλικ στο παρακάτω κουμπί για να φορτώσει ξανά η σελίδα.",
|
||||||
"components.StatusBadge.status4k": "4K {status}",
|
"components.StatusBadge.status4k": "4K {status}",
|
||||||
|
|||||||
@@ -217,8 +217,10 @@
|
|||||||
"components.Layout.UserWarnings.passwordRequired": "A password is required.",
|
"components.Layout.UserWarnings.passwordRequired": "A password is required.",
|
||||||
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind",
|
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind",
|
||||||
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
||||||
"components.Layout.VersionStatus.streamdevelop": "Overseerr Develop",
|
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
|
||||||
"components.Layout.VersionStatus.streamstable": "Overseerr Stable",
|
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
|
||||||
|
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||||
|
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
"components.Login.credentialerror": "The username or password is incorrect.",
|
||||||
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
|
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
|
||||||
"components.Login.email": "Email Address",
|
"components.Login.email": "Email Address",
|
||||||
@@ -582,7 +584,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "You must provide an access token",
|
"components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "You must provide an access token",
|
||||||
"components.Settings.Notifications.NotificationsPushbullet.validationTypes": "You must select at least one notification type",
|
"components.Settings.Notifications.NotificationsPushbullet.validationTypes": "You must select at least one notification type",
|
||||||
"components.Settings.Notifications.NotificationsPushover.accessToken": "Application API Token",
|
"components.Settings.Notifications.NotificationsPushover.accessToken": "Application API Token",
|
||||||
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Overseerr",
|
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr",
|
||||||
"components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent",
|
||||||
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Device Default",
|
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Device Default",
|
||||||
"components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.",
|
"components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.",
|
||||||
@@ -607,7 +609,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL",
|
"components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL",
|
||||||
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
|
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Overseerr must be served over HTTPS.",
|
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Jellyseerr must be served over HTTPS.",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.",
|
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "Sending web push test notification…",
|
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "Sending web push test notification…",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSuccess": "Web push test notification sent!",
|
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSuccess": "Web push test notification sent!",
|
||||||
@@ -633,7 +635,7 @@
|
|||||||
"components.Settings.Notifications.authPass": "SMTP Password",
|
"components.Settings.Notifications.authPass": "SMTP Password",
|
||||||
"components.Settings.Notifications.authUser": "SMTP Username",
|
"components.Settings.Notifications.authUser": "SMTP Username",
|
||||||
"components.Settings.Notifications.botAPI": "Bot Authorization Token",
|
"components.Settings.Notifications.botAPI": "Bot Authorization Token",
|
||||||
"components.Settings.Notifications.botApiTip": "<CreateBotLink>Create a bot</CreateBotLink> for use with Overseerr",
|
"components.Settings.Notifications.botApiTip": "<CreateBotLink>Create a bot</CreateBotLink> for use with Jellyseerr",
|
||||||
"components.Settings.Notifications.botAvatarUrl": "Bot Avatar URL",
|
"components.Settings.Notifications.botAvatarUrl": "Bot Avatar URL",
|
||||||
"components.Settings.Notifications.botUsername": "Bot Username",
|
"components.Settings.Notifications.botUsername": "Bot Username",
|
||||||
"components.Settings.Notifications.botUsernameTip": "Allow users to also start a chat with your bot and configure their own notifications",
|
"components.Settings.Notifications.botUsernameTip": "Allow users to also start a chat with your bot and configure their own notifications",
|
||||||
@@ -748,11 +750,11 @@
|
|||||||
"components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions",
|
"components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions",
|
||||||
"components.Settings.SettingsAbout.helppaycoffee": "Help Pay for Coffee",
|
"components.Settings.SettingsAbout.helppaycoffee": "Help Pay for Coffee",
|
||||||
"components.Settings.SettingsAbout.outofdate": "Out of Date",
|
"components.Settings.SettingsAbout.outofdate": "Out of Date",
|
||||||
"components.Settings.SettingsAbout.overseerrinformation": "About Overseerr",
|
"components.Settings.SettingsAbout.overseerrinformation": "About Jellyseerr",
|
||||||
"components.Settings.SettingsAbout.preferredmethod": "Preferred",
|
"components.Settings.SettingsAbout.preferredmethod": "Preferred",
|
||||||
"components.Settings.SettingsAbout.runningDevelop": "You are running the <code>develop</code> branch of Overseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.",
|
"components.Settings.SettingsAbout.runningDevelop": "You are running the <code>develop</code> branch of Jellyseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.",
|
||||||
"components.Settings.SettingsAbout.supportoverseerr": "Support Overseerr",
|
|
||||||
"components.Settings.SettingsAbout.supportjellyseerr": "Support Jellyseerr",
|
"components.Settings.SettingsAbout.supportjellyseerr": "Support Jellyseerr",
|
||||||
|
"components.Settings.SettingsAbout.supportoverseerr": "Support Overseerr",
|
||||||
"components.Settings.SettingsAbout.timezone": "Time Zone",
|
"components.Settings.SettingsAbout.timezone": "Time Zone",
|
||||||
"components.Settings.SettingsAbout.totalmedia": "Total Media",
|
"components.Settings.SettingsAbout.totalmedia": "Total Media",
|
||||||
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
||||||
@@ -760,7 +762,7 @@
|
|||||||
"components.Settings.SettingsAbout.version": "Version",
|
"components.Settings.SettingsAbout.version": "Version",
|
||||||
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
|
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
|
||||||
"components.Settings.SettingsJobsCache.cache": "Cache",
|
"components.Settings.SettingsJobsCache.cache": "Cache",
|
||||||
"components.Settings.SettingsJobsCache.cacheDescription": "Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
|
"components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
|
||||||
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
|
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
|
||||||
"components.Settings.SettingsJobsCache.cachehits": "Hits",
|
"components.Settings.SettingsJobsCache.cachehits": "Hits",
|
||||||
"components.Settings.SettingsJobsCache.cachekeys": "Total Keys",
|
"components.Settings.SettingsJobsCache.cachekeys": "Total Keys",
|
||||||
@@ -781,7 +783,7 @@
|
|||||||
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
||||||
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
|
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
|
||||||
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
|
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
|
||||||
"components.Settings.SettingsJobsCache.imagecacheDescription": "When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.",
|
"components.Settings.SettingsJobsCache.imagecacheDescription": "When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.",
|
||||||
"components.Settings.SettingsJobsCache.imagecachecount": "Images Cached",
|
"components.Settings.SettingsJobsCache.imagecachecount": "Images Cached",
|
||||||
"components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size",
|
"components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size",
|
||||||
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
||||||
@@ -791,7 +793,7 @@
|
|||||||
"components.Settings.SettingsJobsCache.jobcancelled": "{jobname} canceled.",
|
"components.Settings.SettingsJobsCache.jobcancelled": "{jobname} canceled.",
|
||||||
"components.Settings.SettingsJobsCache.jobname": "Job Name",
|
"components.Settings.SettingsJobsCache.jobname": "Job Name",
|
||||||
"components.Settings.SettingsJobsCache.jobs": "Jobs",
|
"components.Settings.SettingsJobsCache.jobs": "Jobs",
|
||||||
"components.Settings.SettingsJobsCache.jobsDescription": "Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.",
|
"components.Settings.SettingsJobsCache.jobsDescription": "Jellyseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.",
|
||||||
"components.Settings.SettingsJobsCache.jobsandcache": "Jobs & Cache",
|
"components.Settings.SettingsJobsCache.jobsandcache": "Jobs & Cache",
|
||||||
"components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.",
|
"components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.",
|
||||||
"components.Settings.SettingsJobsCache.jobtype": "Type",
|
"components.Settings.SettingsJobsCache.jobtype": "Type",
|
||||||
@@ -832,7 +834,7 @@
|
|||||||
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
||||||
"components.Settings.SettingsMain.general": "General",
|
"components.Settings.SettingsMain.general": "General",
|
||||||
"components.Settings.SettingsMain.generalsettings": "General Settings",
|
"components.Settings.SettingsMain.generalsettings": "General Settings",
|
||||||
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Overseerr.",
|
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Jellyseerr.",
|
||||||
"components.Settings.SettingsMain.hideAvailable": "Hide Available Media",
|
"components.Settings.SettingsMain.hideAvailable": "Hide Available Media",
|
||||||
"components.Settings.SettingsMain.locale": "Display Language",
|
"components.Settings.SettingsMain.locale": "Display Language",
|
||||||
"components.Settings.SettingsMain.originallanguage": "Discover Language",
|
"components.Settings.SettingsMain.originallanguage": "Discover Language",
|
||||||
@@ -845,7 +847,7 @@
|
|||||||
"components.Settings.SettingsMain.toastSettingsFailure": "Something went wrong while saving settings.",
|
"components.Settings.SettingsMain.toastSettingsFailure": "Something went wrong while saving settings.",
|
||||||
"components.Settings.SettingsMain.toastSettingsSuccess": "Settings saved successfully!",
|
"components.Settings.SettingsMain.toastSettingsSuccess": "Settings saved successfully!",
|
||||||
"components.Settings.SettingsMain.trustProxy": "Enable Proxy Support",
|
"components.Settings.SettingsMain.trustProxy": "Enable Proxy Support",
|
||||||
"components.Settings.SettingsMain.trustProxyTip": "Allow Overseerr to correctly register client IP addresses behind a proxy",
|
"components.Settings.SettingsMain.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy",
|
||||||
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
||||||
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
||||||
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
@@ -937,17 +939,21 @@
|
|||||||
"components.Settings.hostname": "Hostname or IP Address",
|
"components.Settings.hostname": "Hostname or IP Address",
|
||||||
"components.Settings.internalUrl": "Internal URL",
|
"components.Settings.internalUrl": "Internal URL",
|
||||||
"components.Settings.is4k": "4K",
|
"components.Settings.is4k": "4K",
|
||||||
|
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
||||||
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
|
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
|
||||||
"components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL.",
|
"components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.",
|
||||||
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
|
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
|
||||||
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
|
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
|
||||||
|
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",
|
||||||
|
"components.Settings.jellyfinSyncFailedGenericError": "Something went wrong while syncing libraries",
|
||||||
|
"components.Settings.jellyfinSyncFailedNoLibrariesFound": "No libraries were found",
|
||||||
"components.Settings.jellyfinlibraries": "{mediaServerName} Libraries",
|
"components.Settings.jellyfinlibraries": "{mediaServerName} Libraries",
|
||||||
"components.Settings.jellyfinlibrariesDescription": "The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.",
|
"components.Settings.jellyfinlibrariesDescription": "The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.",
|
||||||
"components.Settings.jellyfinsettings": "{mediaServerName} Settings",
|
"components.Settings.jellyfinsettings": "{mediaServerName} Settings",
|
||||||
"components.Settings.jellyfinsettingsDescription": "Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.",
|
"components.Settings.jellyfinsettingsDescription": "Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.",
|
||||||
"components.Settings.librariesRemaining": "Libraries Remaining: {count}",
|
"components.Settings.librariesRemaining": "Libraries Remaining: {count}",
|
||||||
"components.Settings.manualscan": "Manual Library Scan",
|
"components.Settings.manualscan": "Manual Library Scan",
|
||||||
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
|
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Jellyseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
|
||||||
"components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Jellyseerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Jellyseerr, a one-time full manual library scan is recommended!",
|
"components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Jellyseerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Jellyseerr, a one-time full manual library scan is recommended!",
|
||||||
"components.Settings.manualscanJellyfin": "Manual Library Scan",
|
"components.Settings.manualscanJellyfin": "Manual Library Scan",
|
||||||
"components.Settings.mediaTypeMovie": "movie",
|
"components.Settings.mediaTypeMovie": "movie",
|
||||||
@@ -970,12 +976,12 @@
|
|||||||
"components.Settings.notrunning": "Not Running",
|
"components.Settings.notrunning": "Not Running",
|
||||||
"components.Settings.plex": "Plex",
|
"components.Settings.plex": "Plex",
|
||||||
"components.Settings.plexlibraries": "Plex Libraries",
|
"components.Settings.plexlibraries": "Plex Libraries",
|
||||||
"components.Settings.plexlibrariesDescription": "The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.",
|
"components.Settings.plexlibrariesDescription": "The libraries Jellyseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.",
|
||||||
"components.Settings.plexsettings": "Plex Settings",
|
"components.Settings.plexsettings": "Plex Settings",
|
||||||
"components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.",
|
"components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Jellyseerr scans your Plex libraries to determine content availability.",
|
||||||
"components.Settings.port": "Port",
|
"components.Settings.port": "Port",
|
||||||
"components.Settings.radarrsettings": "Radarr Settings",
|
"components.Settings.radarrsettings": "Radarr Settings",
|
||||||
"components.Settings.restartrequiredTooltip": "Overseerr must be restarted for changes to this setting to take effect",
|
"components.Settings.restartrequiredTooltip": "Jellyseerr must be restarted for changes to this setting to take effect",
|
||||||
"components.Settings.save": "Save Changes",
|
"components.Settings.save": "Save Changes",
|
||||||
"components.Settings.saving": "Saving…",
|
"components.Settings.saving": "Saving…",
|
||||||
"components.Settings.scan": "Sync Libraries",
|
"components.Settings.scan": "Sync Libraries",
|
||||||
@@ -997,7 +1003,7 @@
|
|||||||
"components.Settings.syncing": "Syncing",
|
"components.Settings.syncing": "Syncing",
|
||||||
"components.Settings.tautulliApiKey": "API Key",
|
"components.Settings.tautulliApiKey": "API Key",
|
||||||
"components.Settings.tautulliSettings": "Tautulli Settings",
|
"components.Settings.tautulliSettings": "Tautulli Settings",
|
||||||
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.",
|
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.",
|
||||||
"components.Settings.timeout": "Timeout",
|
"components.Settings.timeout": "Timeout",
|
||||||
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
|
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
|
||||||
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
|
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
|
||||||
@@ -1171,6 +1177,8 @@
|
|||||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
|
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||||
|
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required",
|
||||||
|
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required",
|
||||||
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default",
|
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default",
|
||||||
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID",
|
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID",
|
||||||
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",
|
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",
|
||||||
|
|||||||