Compare commits
38 Commits
fix-media-
...
preview-dn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce5925426 | ||
|
|
38ad875dd7 | ||
|
|
a9741fa36d | ||
|
|
b5a069901a | ||
|
|
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 |
@@ -322,6 +322,60 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"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
|
||||||
|
|||||||
10
.github/workflows/preview.yml
vendored
@@ -11,21 +11,21 @@ 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
|
||||||
|
|||||||
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'
|
||||||
|
|||||||
10
README.md
@@ -11,7 +11,7 @@
|
|||||||
<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="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-34-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.
|
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||||
@@ -229,6 +229,14 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<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="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="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://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>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"region": "",
|
"region": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
@@ -37,6 +38,17 @@
|
|||||||
],
|
],
|
||||||
"machineId": "test"
|
"machineId": "test"
|
||||||
},
|
},
|
||||||
|
"jellyfin": {
|
||||||
|
"name": "",
|
||||||
|
"ip": "",
|
||||||
|
"port": 8096,
|
||||||
|
"useSsl": false,
|
||||||
|
"urlBase": "",
|
||||||
|
"externalHostname": "",
|
||||||
|
"jellyfinForgotPasswordUrl": "",
|
||||||
|
"libraries": [],
|
||||||
|
"serverId": ""
|
||||||
|
},
|
||||||
"tautulli": {},
|
"tautulli": {},
|
||||||
"radarr": [],
|
"radarr": [],
|
||||||
"sonarr": [],
|
"sonarr": [],
|
||||||
@@ -139,11 +151,26 @@
|
|||||||
"sonarr-scan": {
|
"sonarr-scan": {
|
||||||
"schedule": "0 30 4 * * *"
|
"schedule": "0 30 4 * * *"
|
||||||
},
|
},
|
||||||
|
"plex-watchlist-sync": {
|
||||||
|
"schedule": "0 */10 * * * *"
|
||||||
|
},
|
||||||
|
"availability-sync": {
|
||||||
|
"schedule": "0 0 5 * * *"
|
||||||
|
},
|
||||||
"download-sync": {
|
"download-sync": {
|
||||||
"schedule": "0 * * * * *"
|
"schedule": "0 * * * * *"
|
||||||
},
|
},
|
||||||
"download-sync-reset": {
|
"download-sync-reset": {
|
||||||
"schedule": "0 0 1 * * *"
|
"schedule": "0 0 1 * * *"
|
||||||
|
},
|
||||||
|
"jellyfin-recently-added-scan": {
|
||||||
|
"schedule": "0 */5 * * * *"
|
||||||
|
},
|
||||||
|
"jellyfin-full-scan": {
|
||||||
|
"schedule": "0 0 3 * * *"
|
||||||
|
},
|
||||||
|
"image-cache-cleanup": {
|
||||||
|
"schedule": "0 0 5 * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2092,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
|
||||||
@@ -3395,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:
|
||||||
@@ -3724,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,14 +1,19 @@
|
|||||||
/* 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 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: {
|
Policy: {
|
||||||
IsAdministrator: boolean;
|
IsAdministrator: boolean;
|
||||||
};
|
};
|
||||||
@@ -24,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;
|
||||||
@@ -80,48 +92,90 @@ 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 {
|
const authenticate = async (useHeaders: boolean) => {
|
||||||
const account = await this.axios.post<JellyfinLoginResponse>(
|
const headers =
|
||||||
|
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
||||||
|
|
||||||
|
return this.post<JellyfinLoginResponse>(
|
||||||
'/Users/AuthenticateByName',
|
'/Users/AuthenticateByName',
|
||||||
{
|
{
|
||||||
Username: Username,
|
Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
}
|
},
|
||||||
|
{ headers }
|
||||||
);
|
);
|
||||||
return account.data;
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await authenticate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Unauthorized');
|
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
||||||
|
label: 'Jellyfin API',
|
||||||
|
ip: ClientIP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await authenticate(false);
|
||||||
|
} catch (e) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,69 +184,106 @@ class JellyfinAPI {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSystemInfo(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const systemInfoResponse = await this.get<any>('/System/Info');
|
||||||
|
|
||||||
|
return systemInfoResponse;
|
||||||
|
} catch (e) {
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -200,24 +291,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) {
|
||||||
@@ -225,23 +307,25 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,36 +333,38 @@ class JellyfinAPI {
|
|||||||
id: string
|
id: string
|
||||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
): 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 (availabilitySync.running) {
|
||||||
if (e.response && e.response.status === 500) {
|
if (e.response && e.response.status === 500) {
|
||||||
return undefined;
|
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;
|
return seasonResponse.Items;
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,11 +373,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) {
|
||||||
@@ -299,7 +385,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
server/constants/error.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum ApiErrorCode {
|
||||||
|
InvalidUrl = 'INVALID_URL',
|
||||||
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
|
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||||
|
NotAdmin = 'NOT_ADMIN',
|
||||||
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
|
Unknown = 'UNKNOWN',
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
|
|||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
@@ -151,11 +152,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;
|
||||||
@@ -211,15 +212,12 @@ class Media {
|
|||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||||
let jellyfinHost =
|
|
||||||
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: getHostname();
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
|
|
||||||
if (this.jellyfinMediaId) {
|
if (this.jellyfinMediaId) {
|
||||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -59,6 +84,16 @@ app
|
|||||||
const settings = getSettings().load();
|
const settings = getSettings().load();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
|
// Overwrite DNS servers
|
||||||
|
if (settings.main.overwriteDnsServers) {
|
||||||
|
cacheable.servers = settings.main.overwriteDnsServers
|
||||||
|
.split(',')
|
||||||
|
.map((server) => server.trim());
|
||||||
|
logger.info('Using custom DNS servers', {
|
||||||
|
label: 'Settings',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
if (
|
if (
|
||||||
settings.plex.libraries.length > 1 &&
|
settings.plex.libraries.length > 1 &&
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
|
||||||
class AvailabilitySync {
|
class AvailabilitySync {
|
||||||
public running = false;
|
public running = false;
|
||||||
@@ -84,7 +85,7 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
@@ -548,7 +549,7 @@ class AvailabilitySync {
|
|||||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||||
: undefined;
|
: null;
|
||||||
} else if (
|
} else if (
|
||||||
mediaServerType === MediaServerType.JELLYFIN ||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
mediaServerType === MediaServerType.EMBY
|
mediaServerType === MediaServerType.EMBY
|
||||||
@@ -556,7 +557,7 @@ class AvailabilitySync {
|
|||||||
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||||
: undefined;
|
: null;
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import AsyncLock from '@server/utils/asyncLock';
|
import AsyncLock from '@server/utils/asyncLock';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { randomUUID as uuid } from 'crypto';
|
import { randomUUID as uuid } from 'crypto';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
@@ -83,13 +84,17 @@ class JellyfinScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +173,9 @@ class JellyfinScanner {
|
|||||||
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}`);
|
||||||
}
|
}
|
||||||
@@ -461,9 +466,9 @@ class JellyfinScanner {
|
|||||||
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: isAllStandardSeasons ? Id : undefined,
|
jellyfinMediaId: isAllStandardSeasons ? Id : null,
|
||||||
jellyfinMediaId4k:
|
jellyfinMediaId4k:
|
||||||
isAll4kSeasons && this.enable4kShow ? Id : undefined,
|
isAll4kSeasons && this.enable4kShow ? Id : null,
|
||||||
status: isAllStandardSeasons
|
status: isAllStandardSeasons
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: newSeasons.some(
|
: newSeasons.some(
|
||||||
@@ -590,8 +595,10 @@ class JellyfinScanner {
|
|||||||
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
hostname,
|
||||||
admin.jellyfinAuthToken,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { runMigrations } from '@server/lib/settings/migrator';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { Permission } from './permissions';
|
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,7 +39,10 @@ export interface PlexSettings {
|
|||||||
|
|
||||||
export interface JellyfinSettings {
|
export interface JellyfinSettings {
|
||||||
name: string;
|
name: string;
|
||||||
hostname: string;
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
useSsl?: boolean;
|
||||||
|
urlBase?: string;
|
||||||
externalHostname?: string;
|
externalHostname?: string;
|
||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
@@ -114,6 +118,7 @@ export interface MainSettings {
|
|||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
overwriteDnsServers?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicSettings {
|
interface PublicSettings {
|
||||||
@@ -130,7 +135,6 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
region: string;
|
region: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
jellyfinHost?: string;
|
|
||||||
jellyfinExternalHost?: string;
|
jellyfinExternalHost?: string;
|
||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
@@ -274,7 +278,7 @@ export type JobId =
|
|||||||
| 'image-cache-cleanup'
|
| 'image-cache-cleanup'
|
||||||
| 'availability-sync';
|
| 'availability-sync';
|
||||||
|
|
||||||
interface AllSettings {
|
export interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
vapidPublic: string;
|
vapidPublic: string;
|
||||||
vapidPrivate: string;
|
vapidPrivate: string;
|
||||||
@@ -291,7 +295,7 @@ interface AllSettings {
|
|||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
||||||
: path.join(__dirname, '../../config/settings.json');
|
: path.join(__dirname, '../../../config/settings.json');
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
private data: AllSettings;
|
private data: AllSettings;
|
||||||
@@ -331,7 +335,10 @@ class Settings {
|
|||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
ip: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
externalHostname: '',
|
externalHostname: '',
|
||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
@@ -547,8 +554,6 @@ class Settings {
|
|||||||
region: this.data.main.region,
|
region: this.data.main.region,
|
||||||
originalLanguage: this.data.main.originalLanguage,
|
originalLanguage: this.data.main.originalLanguage,
|
||||||
mediaServerType: this.main.mediaServerType,
|
mediaServerType: this.main.mediaServerType,
|
||||||
jellyfinHost: this.jellyfin.hostname,
|
|
||||||
jellyfinExternalHost: this.jellyfin.externalHostname,
|
|
||||||
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
||||||
cacheImages: this.data.main.cacheImages,
|
cacheImages: this.data.main.cacheImages,
|
||||||
vapidPublic: this.vapidPublic,
|
vapidPublic: this.vapidPublic,
|
||||||
@@ -637,7 +642,11 @@ class Settings {
|
|||||||
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
this.data = merge(this.data, JSON.parse(data));
|
const parsedJson = JSON.parse(data);
|
||||||
|
this.data = runMigrations(parsedJson);
|
||||||
|
|
||||||
|
this.data = merge(this.data, parsedJson);
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
30
server/lib/settings/migrations/0001_migrate_hostname.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
const migrateHostname = (settings: any): AllSettings => {
|
||||||
|
const oldJellyfinSettings = settings.jellyfin;
|
||||||
|
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
|
||||||
|
const { hostname } = oldJellyfinSettings;
|
||||||
|
const protocolMatch = hostname.match(/^(https?):\/\//i);
|
||||||
|
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
|
||||||
|
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
|
||||||
|
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
|
||||||
|
|
||||||
|
delete oldJellyfinSettings.hostname;
|
||||||
|
if (urlMatch) {
|
||||||
|
const [, ip, , port, urlBase] = urlMatch;
|
||||||
|
settings.jellyfin = {
|
||||||
|
...settings.jellyfin,
|
||||||
|
ip,
|
||||||
|
port: port || (useSsl ? 443 : 80),
|
||||||
|
useSsl,
|
||||||
|
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.jellyfin && settings.jellyfin.hostname) {
|
||||||
|
delete settings.jellyfin.hostname;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateHostname;
|
||||||
21
server/lib/settings/migrator.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
|
|
||||||
|
export const runMigrations = (settings: AllSettings): AllSettings => {
|
||||||
|
const migrations = fs
|
||||||
|
.readdirSync(migrationsDir)
|
||||||
|
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
.map((file) => require(path.join(migrationsDir, file)).default);
|
||||||
|
|
||||||
|
let migrated = settings;
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
migrated = migration(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
};
|
||||||
@@ -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,9 +10,12 @@ 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 { getHostname } from '@server/utils/getHostname';
|
||||||
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 gravatarUrl from 'gravatar-url';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
const authRoutes = Router();
|
const authRoutes = Router();
|
||||||
|
|
||||||
@@ -219,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
urlBase?: string;
|
||||||
|
useSsl?: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
settings.jellyfin.hostname !== ''
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||||
} else if (!body.username) {
|
} else if (!body.username) {
|
||||||
return res.status(500).json({ error: 'You must provide an username' });
|
return res.status(500).json({ error: 'You must provide an username' });
|
||||||
} else if (settings.jellyfin.hostname !== '' && body.hostname) {
|
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
.json({ error: 'Jellyfin hostname already configured' });
|
.json({ error: 'Jellyfin hostname already configured' });
|
||||||
} else if (settings.jellyfin.hostname === '' && !body.hostname) {
|
} else if (settings.jellyfin.ip === '' && !body.hostname) {
|
||||||
return res.status(500).json({ error: 'No hostname provided.' });
|
return res.status(500).json({ error: 'No hostname provided.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostname =
|
const hostname =
|
||||||
settings.jellyfin.hostname !== ''
|
settings.jellyfin.ip !== ''
|
||||||
? settings.jellyfin.hostname
|
? getHostname()
|
||||||
: body.hostname ?? '';
|
: getHostname({
|
||||||
|
useSsl: body.useSsl,
|
||||||
|
ip: body.hostname,
|
||||||
|
port: body.port,
|
||||||
|
urlBase: body.urlBase,
|
||||||
|
});
|
||||||
|
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
|
|
||||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||||
@@ -258,18 +271,31 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First we need to attempt to log the user in to jellyfin
|
// First we need to attempt to log the user in to jellyfin
|
||||||
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||||
let jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
const ip = req.ip;
|
||||||
? jellyfinHost.slice(0, -1)
|
let clientIp;
|
||||||
: jellyfinHost;
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
const account = await jellyfinserver.login(body.username, body.password);
|
|
||||||
// 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 },
|
||||||
@@ -278,7 +304,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
if (!user && !(await userRepository.count())) {
|
if (!user && !(await userRepository.count())) {
|
||||||
// Check if user is admin on jellyfin
|
// Check if user is admin on jellyfin
|
||||||
if (account.User.Policy.IsAdministrator === false) {
|
if (account.User.Policy.IsAdministrator === false) {
|
||||||
throw new Error('not_admin');
|
throw new ApiError(403, ApiErrorCode.NotAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -306,15 +332,21 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
settings.jellyfin.hostname = body.hostname ?? '';
|
const serverName = await jellyfinserver.getServerName();
|
||||||
|
|
||||||
|
settings.jellyfin.name = serverName;
|
||||||
settings.jellyfin.serverId = account.User.ServerId;
|
settings.jellyfin.serverId = account.User.ServerId;
|
||||||
|
settings.jellyfin.ip = body.hostname ?? '';
|
||||||
|
settings.jellyfin.port = body.port ?? 8096;
|
||||||
|
settings.jellyfin.urlBase = body.urlBase ?? '';
|
||||||
|
settings.jellyfin.useSsl = body.useSsl ?? false;
|
||||||
settings.save();
|
settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
}
|
}
|
||||||
// User already exists, let's update their information
|
// User already exists, let's update their information
|
||||||
else if (body.username === user?.jellyfinUsername) {
|
else if (account.User.Id === user?.jellyfinUserId) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Found matching ${
|
`Found matching ${
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
@@ -412,43 +444,68 @@ 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: getHostname({
|
||||||
);
|
useSsl: body.useSsl,
|
||||||
return next({
|
ip: body.hostname,
|
||||||
status: 401,
|
port: body.port,
|
||||||
message: 'Unauthorized',
|
urlBase: body.urlBase,
|
||||||
});
|
}),
|
||||||
} else if (e.message === 'not_admin') {
|
}
|
||||||
return next({
|
);
|
||||||
status: 403,
|
return next({
|
||||||
message: 'CREDENTIAL_ERROR_NOT_ADMIN',
|
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 if (e.message === 'select_server_type') {
|
{
|
||||||
return next({
|
label: 'Auth',
|
||||||
status: 406,
|
account: {
|
||||||
message: 'CREDENTIAL_ERROR_NO_SERVER_TYPE',
|
ip: req.ip,
|
||||||
});
|
email: body.username,
|
||||||
} else {
|
password: '__REDACTED__',
|
||||||
logger.error(e.message, { label: 'Auth' });
|
},
|
||||||
return next({
|
}
|
||||||
status: 500,
|
);
|
||||||
message: 'Something went wrong.',
|
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.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
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';
|
||||||
@@ -24,8 +25,10 @@ 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 discoverSettingRoutes from '@server/routes/settings/discover';
|
import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||||
|
import { ApiError } from '@server/types/error';
|
||||||
import { appDataPath } from '@server/utils/appDataVolume';
|
import { appDataPath } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
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';
|
||||||
@@ -252,16 +255,64 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
|
|||||||
res.status(200).json(settings.jellyfin);
|
res.status(200).json(settings.jellyfin);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/jellyfin', (req, res) => {
|
settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.jellyfin = merge(settings.jellyfin, req.body);
|
try {
|
||||||
settings.save();
|
const admin = await userRepository.findOneOrFail({
|
||||||
|
where: { id: 1 },
|
||||||
|
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
|
||||||
|
|
||||||
|
const jellyfinClient = new JellyfinAPI(
|
||||||
|
getHostname(tempJellyfinSettings),
|
||||||
|
admin.jellyfinAuthToken ?? '',
|
||||||
|
admin.jellyfinDeviceId ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await jellyfinClient.getSystemInfo();
|
||||||
|
|
||||||
|
if (!result?.Id) {
|
||||||
|
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(settings.jellyfin, req.body);
|
||||||
|
settings.jellyfin.serverId = result.Id;
|
||||||
|
settings.jellyfin.name = result.ServerName;
|
||||||
|
settings.save();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
logger.error('Something went wrong testing Jellyfin connection', {
|
||||||
|
label: 'API',
|
||||||
|
status: e.statusCode,
|
||||||
|
errorMessage: ApiErrorCode.InvalidUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next({
|
||||||
|
status: e.statusCode,
|
||||||
|
message: ApiErrorCode.InvalidUrl,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Something went wrong', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next({
|
||||||
|
status: e.statusCode ?? 500,
|
||||||
|
message: ApiErrorCode.Unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
getHostname(),
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
@@ -281,6 +332,22 @@ 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: ApiErrorCode.SyncErrorGroupedFolders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -309,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const settings = getSettings();
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
const jellyfinHost =
|
||||||
let jellyfinHost =
|
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: getHostname();
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
@@ -326,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { hasPermission, 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 { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
import { findIndex, sortBy } from 'lodash';
|
import { findIndex, sortBy } from 'lodash';
|
||||||
@@ -496,7 +497,6 @@ router.post(
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
@@ -504,15 +504,14 @@ router.post(
|
|||||||
|
|
||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
let jellyfinHost =
|
const hostname = getHostname();
|
||||||
|
|
||||||
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/utils/getHostname.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
interface HostnameParams {
|
||||||
|
useSsl?: boolean;
|
||||||
|
ip?: string;
|
||||||
|
port?: number;
|
||||||
|
urlBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHostname = (params?: HostnameParams): string => {
|
||||||
|
const settings = params ? params : getSettings().jellyfin;
|
||||||
|
|
||||||
|
const { useSsl, ip, port, urlBase } = settings;
|
||||||
|
|
||||||
|
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
|
||||||
|
|
||||||
|
return hostname;
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -13,7 +14,10 @@ import * as Yup from 'yup';
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
host: '{mediaServerName} URL',
|
hostname: '{mediaServerName} URL',
|
||||||
|
port: 'Port',
|
||||||
|
enablessl: 'Use SSL',
|
||||||
|
urlBase: 'URL Base',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailtooltip:
|
emailtooltip:
|
||||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||||
@@ -23,9 +27,15 @@ const messages = defineMessages({
|
|||||||
validationemailformat: 'Valid email required',
|
validationemailformat: 'Valid email required',
|
||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||||
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||||
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.',
|
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…',
|
||||||
@@ -49,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
|
|
||||||
if (initial) {
|
if (initial) {
|
||||||
const LoginSchema = Yup.object().shape({
|
const LoginSchema = Yup.object().shape({
|
||||||
host: Yup.string()
|
hostname: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationhostrequired, {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
port: Yup.number().required(
|
||||||
|
intl.formatMessage(messages.validationPortRequired)
|
||||||
|
),
|
||||||
|
urlBase: Yup.string()
|
||||||
.matches(
|
.matches(
|
||||||
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/,
|
/^(\/[^/].*[^/]$)/,
|
||||||
intl.formatMessage(messages.validationhostformat)
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
|
||||||
)
|
)
|
||||||
.required(
|
.matches(
|
||||||
intl.formatMessage(messages.validationhostrequired, {
|
/^(.*[^/])$/,
|
||||||
mediaServerName:
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
email: Yup.string()
|
email: Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
@@ -73,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
mediaServerName:
|
mediaServerName:
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
host: '',
|
hostname: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
email: '',
|
email: '',
|
||||||
}}
|
}}
|
||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
@@ -87,18 +108,31 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
await axios.post('/api/v1/auth/jellyfin', {
|
await axios.post('/api/v1/auth/jellyfin', {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
hostname: values.host,
|
hostname: values.hostname,
|
||||||
|
port: values.port,
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
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
|
|
||||||
: e.message == 'Request failed with status code 403'
|
|
||||||
? messages.adminerror
|
|
||||||
: messages.loginerror
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
@@ -109,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ errors, touched, isSubmitting, isValid }) => (
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => (
|
||||||
<Form>
|
<Form>
|
||||||
<div className="sm:border-t sm:border-gray-800">
|
<div className="sm:border-t sm:border-gray-800">
|
||||||
<label htmlFor="host" className="text-label">
|
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||||
{intl.formatMessage(messages.host, mediaServerFormatValues)}
|
<div className="w-full">
|
||||||
|
<label htmlFor="hostname" className="text-label">
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.hostname,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
{values.useSsl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
|
<Field
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
type="text"
|
||||||
|
className="rounded-r-only flex-1"
|
||||||
|
placeholder={intl.formatMessage(
|
||||||
|
messages.hostname,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.hostname && touched.hostname && (
|
||||||
|
<div className="error">{errors.hostname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="port" className="text-label">
|
||||||
|
{intl.formatMessage(messages.port)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 sm:mt-0">
|
||||||
|
<Field
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
inputMode="numeric"
|
||||||
|
type="text"
|
||||||
|
className="short flex-1"
|
||||||
|
placeholder={intl.formatMessage(messages.port)}
|
||||||
|
/>
|
||||||
|
{errors.port && touched.port && (
|
||||||
|
<div className="error">{errors.port}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="useSsl" className="text-label mt-2">
|
||||||
|
{intl.formatMessage(messages.enablessl)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="useSsl"
|
||||||
|
name="useSsl"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('useSsl', !values.useSsl);
|
||||||
|
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="urlBase" className="text-label mt-1">
|
||||||
|
{intl.formatMessage(messages.urlBase)}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
<Field
|
<Field
|
||||||
id="host"
|
|
||||||
name="host"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(
|
inputMode="url"
|
||||||
messages.host,
|
id="urlBase"
|
||||||
mediaServerFormatValues
|
name="urlBase"
|
||||||
)}
|
placeholder={intl.formatMessage(messages.urlBase)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.host && touched.host && (
|
{errors.urlBase && touched.urlBase && (
|
||||||
<div className="error">{errors.host}</div>
|
<div className="error">{errors.urlBase}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="text-label"
|
className="text-label inline-flex gap-1 align-middle"
|
||||||
style={{ display: 'inline-flex' }}
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
<span className="label-tip">
|
<span className="label-tip">
|
||||||
@@ -150,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
<Field
|
<Field
|
||||||
id="email"
|
id="email"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import LibraryItem from '@app/components/Settings/LibraryItem';
|
import LibraryItem from '@app/components/Settings/LibraryItem';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import type { JellyfinSettings } from '@server/lib/settings';
|
import type { JellyfinSettings } from '@server/lib/settings';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
@@ -32,9 +33,17 @@ const messages = defineMessages({
|
|||||||
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. 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.',
|
'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',
|
hostname: 'Hostname or IP Address',
|
||||||
|
port: 'Port',
|
||||||
|
enablessl: 'Use SSL',
|
||||||
|
urlBase: 'URL Base',
|
||||||
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
||||||
validationUrl: 'You must provide a valid URL',
|
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
||||||
|
jellyfinSyncFailedAutomaticGroupedFolders:
|
||||||
|
'Custom authentication with Automatic Library Grouping not supported',
|
||||||
|
jellyfinSyncFailedGenericError:
|
||||||
|
'Something went wrong while syncing libraries',
|
||||||
|
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||||
syncing: 'Syncing',
|
syncing: 'Syncing',
|
||||||
syncJellyfin: 'Sync Libraries',
|
syncJellyfin: 'Sync Libraries',
|
||||||
manualscanJellyfin: 'Manual Library Scan',
|
manualscanJellyfin: 'Manual Library Scan',
|
||||||
@@ -45,6 +54,12 @@ const messages = defineMessages({
|
|||||||
librariesRemaining: 'Libraries Remaining: {count}',
|
librariesRemaining: 'Libraries Remaining: {count}',
|
||||||
startscan: 'Start Scan',
|
startscan: 'Start Scan',
|
||||||
cancelscan: 'Cancel Scan',
|
cancelscan: 'Cancel Scan',
|
||||||
|
validationUrl: 'You must provide a valid URL',
|
||||||
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||||
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Library {
|
interface Library {
|
||||||
@@ -60,6 +75,7 @@ interface SyncStatus {
|
|||||||
currentLibrary?: Library;
|
currentLibrary?: Library;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsJellyfinProps {
|
interface SettingsJellyfinProps {
|
||||||
showAdvancedSettings?: boolean;
|
showAdvancedSettings?: boolean;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
@@ -70,6 +86,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,
|
||||||
@@ -87,18 +104,50 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
const { publicRuntimeConfig } = getConfig();
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
const JellyfinSettingsSchema = Yup.object().shape({
|
const JellyfinSettingsSchema = Yup.object().shape({
|
||||||
jellyfinExternalUrl: Yup.string().matches(
|
hostname: Yup.string()
|
||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
.nullable()
|
||||||
intl.formatMessage(messages.validationUrl)
|
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||||
),
|
.matches(
|
||||||
jellyfinInternalUrl: Yup.string().matches(
|
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
intl.formatMessage(messages.validationHostnameRequired)
|
||||||
intl.formatMessage(messages.validationUrl)
|
),
|
||||||
),
|
port: Yup.number().when(['hostname'], {
|
||||||
jellyfinForgotPasswordUrl: Yup.string().matches(
|
is: (value: unknown) => !!value,
|
||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
then: Yup.number()
|
||||||
intl.formatMessage(messages.validationUrl)
|
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||||
),
|
.nullable()
|
||||||
|
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||||
|
otherwise: Yup.number()
|
||||||
|
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||||
|
.nullable(),
|
||||||
|
}),
|
||||||
|
urlBase: Yup.string()
|
||||||
|
.test(
|
||||||
|
'leading-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||||
|
(value) => !value || value.startsWith('/')
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
jellyfinExternalUrl: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.url(intl.formatMessage(messages.validationUrl))
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
jellyfinForgotPasswordUrl: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.url(intl.formatMessage(messages.validationUrl))
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeLibraries =
|
const activeLibraries =
|
||||||
@@ -117,11 +166,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 () => {
|
||||||
@@ -356,7 +437,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
jellyfinInternalUrl: data?.hostname || '',
|
hostname: data?.ip,
|
||||||
|
port: data?.port ?? 8096,
|
||||||
|
useSsl: data?.useSsl,
|
||||||
|
urlBase: data?.urlBase || '',
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
}}
|
}}
|
||||||
@@ -364,7 +448,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/jellyfin', {
|
await axios.post('/api/v1/settings/jellyfin', {
|
||||||
hostname: values.jellyfinInternalUrl,
|
ip: values.hostname,
|
||||||
|
port: Number(values.port),
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
} as JellyfinSettings);
|
} as JellyfinSettings);
|
||||||
@@ -382,44 +469,127 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(
|
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
|
||||||
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
addToast(
|
||||||
mediaServerName:
|
intl.formatMessage(messages.invalidurlerror, {
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
mediaServerName:
|
||||||
? 'Emby'
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
: 'Jellyfin',
|
? 'Emby'
|
||||||
}),
|
: 'Jellyfin',
|
||||||
{
|
}),
|
||||||
autoDismiss: true,
|
{
|
||||||
appearance: 'error',
|
autoDismiss: true,
|
||||||
}
|
appearance: 'error',
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
handleSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<form className="section" onSubmit={handleSubmit}>
|
<form className="section" onSubmit={handleSubmit}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="jellyfinInternalUrl" className="text-label">
|
<label htmlFor="hostname" className="text-label">
|
||||||
{intl.formatMessage(messages.internalUrl)}
|
{intl.formatMessage(messages.hostname)}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
{values.useSsl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
className="rounded-r-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.hostname &&
|
||||||
|
touched.hostname &&
|
||||||
|
typeof errors.hostname === 'string' && (
|
||||||
|
<div className="error">{errors.hostname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="port" className="text-label">
|
||||||
|
{intl.formatMessage(messages.port)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
className="short"
|
||||||
|
/>
|
||||||
|
{errors.port &&
|
||||||
|
touched.port &&
|
||||||
|
typeof errors.port === 'string' && (
|
||||||
|
<div className="error">{errors.port}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="useSsl" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.enablessl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="useSsl"
|
||||||
|
name="useSsl"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('useSsl', !values.useSsl);
|
||||||
|
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="urlBase" className="text-label">
|
||||||
|
{intl.formatMessage(messages.urlBase)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
id="jellyfinInternalUrl"
|
id="urlBase"
|
||||||
name="jellyfinInternalUrl"
|
name="urlBase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.jellyfinInternalUrl &&
|
{errors.urlBase &&
|
||||||
touched.jellyfinInternalUrl && (
|
touched.urlBase &&
|
||||||
<div className="error">
|
typeof errors.urlBase === 'string' && (
|
||||||
{errors.jellyfinInternalUrl}
|
<div className="error">{errors.urlBase}</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ const messages = defineMessages({
|
|||||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||||
locale: 'Display Language',
|
locale: 'Display Language',
|
||||||
|
overwriteDnsServers: 'Overwrite Default DNS Servers',
|
||||||
|
overwriteDnsServersTip:
|
||||||
|
'A comma-separated list of DNS server to use instead of the default ones',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsMain = () => {
|
const SettingsMain = () => {
|
||||||
@@ -134,6 +137,7 @@ const SettingsMain = () => {
|
|||||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||||
trustProxy: data?.trustProxy,
|
trustProxy: data?.trustProxy,
|
||||||
cacheImages: data?.cacheImages,
|
cacheImages: data?.cacheImages,
|
||||||
|
overwriteDnsServers: data?.overwriteDnsServers,
|
||||||
}}
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
validationSchema={MainSettingsSchema}
|
validationSchema={MainSettingsSchema}
|
||||||
@@ -150,6 +154,7 @@ const SettingsMain = () => {
|
|||||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||||
trustProxy: values.trustProxy,
|
trustProxy: values.trustProxy,
|
||||||
cacheImages: values.cacheImages,
|
cacheImages: values.cacheImages,
|
||||||
|
overwriteDnsServers: values.overwriteDnsServers,
|
||||||
});
|
});
|
||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
mutate('/api/v1/status');
|
mutate('/api/v1/status');
|
||||||
@@ -427,6 +432,37 @@ const SettingsMain = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="overwriteDnsServers"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.overwriteDnsServers)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||||
|
<SettingsBadge badgeType="restartRequired" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.overwriteDnsServersTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="overwriteDnsServers"
|
||||||
|
name="overwriteDnsServers"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.overwriteDnsServers &&
|
||||||
|
touched.overwriteDnsServers &&
|
||||||
|
typeof errors.overwriteDnsServers === 'string' && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.overwriteDnsServers}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import type { SonarrSettings } from '@server/lib/settings';
|
||||||
import { type SonarrSettings } from '@server/lib/settings';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -111,7 +109,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const settings = useSettings();
|
|
||||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||||
profiles: [],
|
profiles: [],
|
||||||
rootFolders: [],
|
rootFolders: [],
|
||||||
@@ -258,9 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
animeTags: sonarr?.animeTags ?? [],
|
animeTags: sonarr?.animeTags ?? [],
|
||||||
isDefault: sonarr?.isDefault ?? false,
|
isDefault: sonarr?.isDefault ?? false,
|
||||||
is4k: sonarr?.is4k ?? false,
|
is4k: sonarr?.is4k ?? false,
|
||||||
enableSeasonFolders:
|
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
|
||||||
sonarr?.enableSeasonFolders ??
|
|
||||||
settings.currentSettings.mediaServerType !== MediaServerType.PLEX,
|
|
||||||
externalUrl: sonarr?.externalUrl,
|
externalUrl: sonarr?.externalUrl,
|
||||||
syncEnabled: sonarr?.syncEnabled ?? false,
|
syncEnabled: sonarr?.syncEnabled ?? false,
|
||||||
enableSearch: !sonarr?.preventSearch,
|
enableSearch: !sonarr?.preventSearch,
|
||||||
@@ -966,24 +961,11 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
>
|
>
|
||||||
{intl.formatMessage(messages.seasonfolders)}
|
{intl.formatMessage(messages.seasonfolders)}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div className="form-input-area">
|
||||||
className={`form-input-area ${
|
|
||||||
settings.currentSettings.mediaServerType ===
|
|
||||||
MediaServerType.JELLYFIN ||
|
|
||||||
settings.currentSettings.mediaServerType ===
|
|
||||||
MediaServerType.EMBY
|
|
||||||
? 'opacity-50'
|
|
||||||
: 'opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Field
|
<Field
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="enableSeasonFolders"
|
id="enableSeasonFolders"
|
||||||
name="enableSeasonFolders"
|
name="enableSeasonFolders"
|
||||||
disabled={
|
|
||||||
settings.currentSettings.mediaServerType !==
|
|
||||||
MediaServerType.PLEX
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,19 +217,22 @@
|
|||||||
"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.credentialerror": "The username or password is incorrect.",
|
|
||||||
"components.Login.adminerror": "You must use an admin account to sign in.",
|
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||||
|
"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",
|
||||||
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
|
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
|
||||||
|
"components.Login.enablessl": "Use SSL",
|
||||||
"components.Login.forgotpassword": "Forgot Password?",
|
"components.Login.forgotpassword": "Forgot Password?",
|
||||||
"components.Login.host": "{mediaServerName} URL",
|
"components.Login.hostname": "{mediaServerName} URL",
|
||||||
"components.Login.initialsignin": "Connect",
|
"components.Login.initialsignin": "Connect",
|
||||||
"components.Login.initialsigningin": "Connecting…",
|
"components.Login.initialsigningin": "Connecting…",
|
||||||
|
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
||||||
"components.Login.password": "Password",
|
"components.Login.password": "Password",
|
||||||
|
"components.Login.port": "Port",
|
||||||
"components.Login.save": "Add",
|
"components.Login.save": "Add",
|
||||||
"components.Login.saving": "Adding…",
|
"components.Login.saving": "Adding…",
|
||||||
"components.Login.signin": "Sign In",
|
"components.Login.signin": "Sign In",
|
||||||
@@ -239,9 +242,15 @@
|
|||||||
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
|
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
|
||||||
"components.Login.signinwithplex": "Use your Plex account",
|
"components.Login.signinwithplex": "Use your Plex account",
|
||||||
"components.Login.title": "Add Email",
|
"components.Login.title": "Add Email",
|
||||||
|
"components.Login.urlBase": "URL Base",
|
||||||
"components.Login.username": "Username",
|
"components.Login.username": "Username",
|
||||||
"components.Login.validationEmailFormat": "Invalid email",
|
"components.Login.validationEmailFormat": "Invalid email",
|
||||||
"components.Login.validationEmailRequired": "You must provide an email",
|
"components.Login.validationEmailRequired": "You must provide an email",
|
||||||
|
"components.Login.validationHostnameRequired": "You must provide a valid hostname or IP address",
|
||||||
|
"components.Login.validationPortRequired": "You must provide a valid port number",
|
||||||
|
"components.Login.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
|
||||||
|
"components.Login.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
|
||||||
|
"components.Login.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
"components.Login.validationemailformat": "Valid email required",
|
"components.Login.validationemailformat": "Valid email required",
|
||||||
"components.Login.validationemailrequired": "You must provide a valid email address",
|
"components.Login.validationemailrequired": "You must provide a valid email address",
|
||||||
"components.Login.validationhostformat": "Valid URL required",
|
"components.Login.validationhostformat": "Valid URL required",
|
||||||
@@ -583,7 +592,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.",
|
||||||
@@ -608,7 +617,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!",
|
||||||
@@ -634,7 +643,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",
|
||||||
@@ -749,11 +758,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",
|
||||||
@@ -761,7 +770,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",
|
||||||
@@ -782,7 +791,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",
|
||||||
@@ -792,7 +801,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",
|
||||||
@@ -833,7 +842,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",
|
||||||
@@ -846,7 +855,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",
|
||||||
@@ -936,19 +945,23 @@
|
|||||||
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
||||||
"components.Settings.externalUrl": "External URL",
|
"components.Settings.externalUrl": "External URL",
|
||||||
"components.Settings.hostname": "Hostname or IP Address",
|
"components.Settings.hostname": "Hostname or IP Address",
|
||||||
"components.Settings.internalUrl": "Internal URL",
|
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"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. 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.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",
|
||||||
@@ -971,12 +984,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",
|
||||||
@@ -998,7 +1011,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.",
|
||||||
@@ -1172,6 +1185,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",
|
||||||
|
|||||||
1205
src/i18n/locale/es_MX.json
Normal file
@@ -1152,7 +1152,7 @@
|
|||||||
"components.Settings.SettingsMain.applicationurl": "URL applicazione",
|
"components.Settings.SettingsMain.applicationurl": "URL applicazione",
|
||||||
"components.Settings.SettingsMain.validationApplicationTitle": "Devi fornire un titolo dell'applicazione",
|
"components.Settings.SettingsMain.validationApplicationTitle": "Devi fornire un titolo dell'applicazione",
|
||||||
"components.Settings.SettingsMain.validationApplicationUrl": "Devi fornire un URL valido",
|
"components.Settings.SettingsMain.validationApplicationUrl": "Devi fornire un URL valido",
|
||||||
"components.Settings.restartrequiredTooltip": "Overseerr deve essere riavviato per rendere effettive le modifiche",
|
"components.Settings.restartrequiredTooltip": "Jellyseerr deve essere riavviato per rendere effettive le modifiche",
|
||||||
"components.TitleCard.tvdbid": "TheTVDB ID",
|
"components.TitleCard.tvdbid": "TheTVDB ID",
|
||||||
"components.UserProfile.emptywatchlist": "I media aggiunti alla tua <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> appariranno qui.",
|
"components.UserProfile.emptywatchlist": "I media aggiunti alla tua <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> appariranno qui.",
|
||||||
"components.TvDetails.status4k": "4K {status}",
|
"components.TvDetails.status4k": "4K {status}",
|
||||||
|
|||||||
@@ -324,13 +324,13 @@
|
|||||||
"components.Settings.SettingsAbout.appDataPath": "데이터 디렉토리",
|
"components.Settings.SettingsAbout.appDataPath": "데이터 디렉토리",
|
||||||
"components.Settings.SettingsAbout.documentation": "문서",
|
"components.Settings.SettingsAbout.documentation": "문서",
|
||||||
"components.Settings.SettingsAbout.gettingsupport": "지원 받기",
|
"components.Settings.SettingsAbout.gettingsupport": "지원 받기",
|
||||||
"components.Settings.SettingsAbout.overseerrinformation": "Overseerr 정보",
|
"components.Settings.SettingsAbout.overseerrinformation": "Jellyseerr 정보",
|
||||||
"components.Settings.SettingsAbout.preferredmethod": "선호",
|
"components.Settings.SettingsAbout.preferredmethod": "선호",
|
||||||
"components.Settings.SettingsAbout.runningDevelop": "당신은 <code>개발</code>에 기여하거나 최신 테스트를 지원하는 사람들에게만 권장되는 Overserr 분기를 실행하고 있습니다.",
|
"components.Settings.SettingsAbout.runningDevelop": "당신은 <code>개발</code>에 기여하거나 최신 테스트를 지원하는 사람들에게만 권장되는 Overserr 분기를 실행하고 있습니다.",
|
||||||
"components.Settings.SettingsAbout.timezone": "시간대",
|
"components.Settings.SettingsAbout.timezone": "시간대",
|
||||||
"components.Settings.SettingsJobsCache.availability-sync": "사용가능한 미디어 동기화",
|
"components.Settings.SettingsJobsCache.availability-sync": "사용가능한 미디어 동기화",
|
||||||
"components.Settings.SettingsJobsCache.cache": "캐시",
|
"components.Settings.SettingsJobsCache.cache": "캐시",
|
||||||
"components.Settings.SettingsJobsCache.cacheDescription": "Overseerr는 외부 API 엔드포인트에 대한 요청을 캐시하여 성능을 최적화하고 불필요한 API 호출을 방지합니다.",
|
"components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr는 외부 API 엔드포인트에 대한 요청을 캐시하여 성능을 최적화하고 불필요한 API 호출을 방지합니다.",
|
||||||
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} 캐시가 플러시되었습니다.",
|
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} 캐시가 플러시되었습니다.",
|
||||||
"components.Settings.SettingsJobsCache.cachekeys": "전체 키",
|
"components.Settings.SettingsJobsCache.cachekeys": "전체 키",
|
||||||
"components.Settings.SettingsJobsCache.cacheksize": "키 크기",
|
"components.Settings.SettingsJobsCache.cacheksize": "키 크기",
|
||||||
@@ -342,11 +342,11 @@
|
|||||||
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "새 주파수",
|
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "새 주파수",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "매 {jobScheduleSeconds, plural, one {초} other {{jobScheduleSeconds} 초}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "매 {jobScheduleSeconds, plural, one {초} other {{jobScheduleSeconds} 초}}",
|
||||||
"components.Settings.SettingsJobsCache.imagecache": "이미지 캐시",
|
"components.Settings.SettingsJobsCache.imagecache": "이미지 캐시",
|
||||||
"components.Settings.SettingsJobsCache.imagecacheDescription": "설정에서 활성화하면 Overseerr는 미리 구성된 외부 소스에서 이미지를 프록시하고 캐시합니다. 캐시된 이미지는 구성 폴더에 저장됩니다. <code>{appDataPath}/cache/images</code> 에서 파일을 찾을 수 있습니다.",
|
"components.Settings.SettingsJobsCache.imagecacheDescription": "설정에서 활성화하면 Jellyseerr는 미리 구성된 외부 소스에서 이미지를 프록시하고 캐시합니다. 캐시된 이미지는 구성 폴더에 저장됩니다. <code>{appDataPath}/cache/images</code> 에서 파일을 찾을 수 있습니다.",
|
||||||
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "작업을 저장하는 동안 문제가 발생했습니다.",
|
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "작업을 저장하는 동안 문제가 발생했습니다.",
|
||||||
"components.Settings.SettingsJobsCache.jobScheduleEditSaved": "작업이 성공적으로 수정되었습니다!",
|
"components.Settings.SettingsJobsCache.jobScheduleEditSaved": "작업이 성공적으로 수정되었습니다!",
|
||||||
"components.Settings.SettingsJobsCache.jobs": "작업",
|
"components.Settings.SettingsJobsCache.jobs": "작업",
|
||||||
"components.Settings.SettingsJobsCache.jobsDescription": "Overseerr는 특정 유지 관리 작업을 정기적으로 예약된 작업으로 수행하지만 아래에서 수동으로 트리거할 수도 있습니다. 작업을 수동으로 실행해도 일정이 변경되지는 않습니다.",
|
"components.Settings.SettingsJobsCache.jobsDescription": "Jellyseerr는 특정 유지 관리 작업을 정기적으로 예약된 작업으로 수행하지만 아래에서 수동으로 트리거할 수도 있습니다. 작업을 수동으로 실행해도 일정이 변경되지는 않습니다.",
|
||||||
"components.Settings.SettingsJobsCache.jobtype": "유형",
|
"components.Settings.SettingsJobsCache.jobtype": "유형",
|
||||||
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex 전체 라이브러리 스캔",
|
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex 전체 라이브러리 스캔",
|
||||||
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex 최근 추가 스캔",
|
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex 최근 추가 스캔",
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
"components.Settings.is4k": "4K",
|
"components.Settings.is4k": "4K",
|
||||||
"components.Settings.manualscan": "수동으로 라이브러리 스캔",
|
"components.Settings.manualscan": "수동으로 라이브러리 스캔",
|
||||||
"components.Settings.mediaTypeMovie": "영화",
|
"components.Settings.mediaTypeMovie": "영화",
|
||||||
"components.Settings.manualscanDescription": "일반적으로 이는 24시간에 한 번 실행됩니다. Overseerr는 Plex 서버의 최근 추가 항목을 더 적극적으로 확인합니다. 만약 Plex를 처음 구성하는 경우, 한번은 전체 수동 라이브러리 스캔을 권장합니다!",
|
"components.Settings.manualscanDescription": "일반적으로 이는 24시간에 한 번 실행됩니다. Jellyseerr는 Plex 서버의 최근 추가 항목을 더 적극적으로 확인합니다. 만약 Plex를 처음 구성하는 경우, 한번은 전체 수동 라이브러리 스캔을 권장합니다!",
|
||||||
"components.Settings.mediaTypeSeries": "시리즈",
|
"components.Settings.mediaTypeSeries": "시리즈",
|
||||||
"components.Settings.menuAbout": "정보",
|
"components.Settings.menuAbout": "정보",
|
||||||
"components.Settings.menuGeneralSettings": "일반",
|
"components.Settings.menuGeneralSettings": "일반",
|
||||||
@@ -431,10 +431,10 @@
|
|||||||
"components.Settings.noDefaultNon4kServer": "비-4K 및 4K 콘텐츠를 전부 처리하는 유일한 {serverType} 서버가 있는 경우(또는 4K 콘텐츠만 다운로드하는 경우), {serverType} 서버는 4K 서버로 지정되어서는 <strong>안됩니다</strong>.",
|
"components.Settings.noDefaultNon4kServer": "비-4K 및 4K 콘텐츠를 전부 처리하는 유일한 {serverType} 서버가 있는 경우(또는 4K 콘텐츠만 다운로드하는 경우), {serverType} 서버는 4K 서버로 지정되어서는 <strong>안됩니다</strong>.",
|
||||||
"components.Settings.noDefaultServer": "{mediaType} 요청을 처리하기 위해서는 적어도 하나 이상의 {serverType} 서버를 기본 설정해야 합니다.",
|
"components.Settings.noDefaultServer": "{mediaType} 요청을 처리하기 위해서는 적어도 하나 이상의 {serverType} 서버를 기본 설정해야 합니다.",
|
||||||
"components.Settings.notifications": "알림",
|
"components.Settings.notifications": "알림",
|
||||||
"components.Settings.plexlibrariesDescription": "Overseerr에서 타이틀을 스캔하는 라이브러리입니다. Plex 연결 설정을 설정하고 저장한 후, 라이브러리가 표시되지 않는 경우 아래 버튼을 클릭하세요.",
|
"components.Settings.plexlibrariesDescription": "Jellyseerr에서 타이틀을 스캔하는 라이브러리입니다. Plex 연결 설정을 설정하고 저장한 후, 라이브러리가 표시되지 않는 경우 아래 버튼을 클릭하세요.",
|
||||||
"components.Settings.port": "포트",
|
"components.Settings.port": "포트",
|
||||||
"components.Settings.radarrsettings": "Radarr 설정",
|
"components.Settings.radarrsettings": "Radarr 설정",
|
||||||
"components.Settings.restartrequiredTooltip": "이 변경된 설정이 적용되려면 Overseerr를 재시작해야 합니다",
|
"components.Settings.restartrequiredTooltip": "이 변경된 설정이 적용되려면 Jellyseerr를 재시작해야 합니다",
|
||||||
"components.Settings.serverRemote": "원격",
|
"components.Settings.serverRemote": "원격",
|
||||||
"components.Settings.serverSecure": "보안",
|
"components.Settings.serverSecure": "보안",
|
||||||
"components.Settings.serverpreset": "서버",
|
"components.Settings.serverpreset": "서버",
|
||||||
@@ -598,7 +598,7 @@
|
|||||||
"components.IssueList.showallissues": "모든 이슈 표시",
|
"components.IssueList.showallissues": "모든 이슈 표시",
|
||||||
"components.IssueModal.CreateIssueModal.toastFailedCreate": "이슈를 제출하는 동안에 문제가 발생했습니다.",
|
"components.IssueModal.CreateIssueModal.toastFailedCreate": "이슈를 제출하는 동안에 문제가 발생했습니다.",
|
||||||
"components.Layout.LanguagePicker.displaylanguage": "표시 언어",
|
"components.Layout.LanguagePicker.displaylanguage": "표시 언어",
|
||||||
"components.Layout.VersionStatus.streamdevelop": "Overseerr 개발",
|
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr 개발",
|
||||||
"components.ManageSlideOver.tvshow": "시리즈",
|
"components.ManageSlideOver.tvshow": "시리즈",
|
||||||
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {재생} other {재생}}",
|
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {재생} other {재생}}",
|
||||||
"components.MovieDetails.rtaudiencescore": "Rotten Tomatoes 시청자 점수",
|
"components.MovieDetails.rtaudiencescore": "Rotten Tomatoes 시청자 점수",
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
"components.Layout.Sidebar.issues": "이슈",
|
"components.Layout.Sidebar.issues": "이슈",
|
||||||
"components.Layout.UserDropdown.myprofile": "프로필",
|
"components.Layout.UserDropdown.myprofile": "프로필",
|
||||||
"components.Login.loginerror": "로그인을 시도하는 중에 문제가 발생했습니다.",
|
"components.Login.loginerror": "로그인을 시도하는 중에 문제가 발생했습니다.",
|
||||||
"components.Layout.VersionStatus.streamstable": "Overseerr 안정",
|
"components.Layout.VersionStatus.streamstable": "Jellyseerr 안정",
|
||||||
"components.Login.signingin": "로그인 중…",
|
"components.Login.signingin": "로그인 중…",
|
||||||
"components.ManageSlideOver.manageModalIssues": "진행 중인 이슈",
|
"components.ManageSlideOver.manageModalIssues": "진행 중인 이슈",
|
||||||
"components.ManageSlideOver.manageModalMedia": "미디어",
|
"components.ManageSlideOver.manageModalMedia": "미디어",
|
||||||
@@ -780,7 +780,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "Pushbullet 테스트 알림이 전송되었습니다!",
|
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "Pushbullet 테스트 알림이 전송되었습니다!",
|
||||||
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestFailed": "Pushbullet 테스트 알림을 보내지 못했습니다.",
|
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestFailed": "Pushbullet 테스트 알림을 보내지 못했습니다.",
|
||||||
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSending": "Pushbullet 테스트 알림 보내기…",
|
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSending": "Pushbullet 테스트 알림 보내기…",
|
||||||
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "Overseerr와 함께 사용할 <ApplicationRegistrationLink>애플리케이션 등록</ApplicationRegistrationLink>",
|
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "Jellyseerr와 함께 사용할 <ApplicationRegistrationLink>애플리케이션 등록</ApplicationRegistrationLink>",
|
||||||
"components.Settings.Notifications.NotificationsPushover.agentenabled": "에이전트 활성화",
|
"components.Settings.Notifications.NotificationsPushover.agentenabled": "에이전트 활성화",
|
||||||
"components.Settings.Notifications.NotificationsPushbullet.validationTypes": "적어도 하나 이상의 알림 유형을 선택해야 합니다",
|
"components.Settings.Notifications.NotificationsPushbullet.validationTypes": "적어도 하나 이상의 알림 유형을 선택해야 합니다",
|
||||||
"components.Settings.Notifications.NotificationsPushover.accessToken": "애플리케이션 API 토큰",
|
"components.Settings.Notifications.NotificationsPushover.accessToken": "애플리케이션 API 토큰",
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "유효한 JSON 페이로드를 입력해야 합니다",
|
"components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "유효한 JSON 페이로드를 입력해야 합니다",
|
||||||
"components.Settings.Notifications.allowselfsigned": "자체 서명된 인증서 허용",
|
"components.Settings.Notifications.allowselfsigned": "자체 서명된 인증서 허용",
|
||||||
"components.Settings.Notifications.authUser": "SMTP 사용자 이름",
|
"components.Settings.Notifications.authUser": "SMTP 사용자 이름",
|
||||||
"components.Settings.Notifications.botApiTip": "Overseerr와 함께 사용할 <CreateBotLink>봇 생성</CreateBotLink>이 필요합니다",
|
"components.Settings.Notifications.botApiTip": "Jellyseerr와 함께 사용할 <CreateBotLink>봇 생성</CreateBotLink>이 필요합니다",
|
||||||
"components.Settings.Notifications.botUsername": "봇 사용자 이름",
|
"components.Settings.Notifications.botUsername": "봇 사용자 이름",
|
||||||
"components.Settings.Notifications.chatIdTip": "봇과 채팅을 시작하고 <GetIdBotLink>@get_id_bot</GetIdBotLink>, <code>/my_id</code> 명령을 실행하세요",
|
"components.Settings.Notifications.chatIdTip": "봇과 채팅을 시작하고 <GetIdBotLink>@get_id_bot</GetIdBotLink>, <code>/my_id</code> 명령을 실행하세요",
|
||||||
"components.Settings.Notifications.discordsettingsfailed": "Discord 알림 설정을 저장하지 못했습니다.",
|
"components.Settings.Notifications.discordsettingsfailed": "Discord 알림 설정을 저장하지 못했습니다.",
|
||||||
@@ -840,7 +840,7 @@
|
|||||||
"components.Settings.SettingsMain.originallanguageTip": "원작 언어로 콘텐츠 필터링",
|
"components.Settings.SettingsMain.originallanguageTip": "원작 언어로 콘텐츠 필터링",
|
||||||
"components.Settings.SettingsMain.partialRequestsEnabled": "부분 시리즈 요청 허용",
|
"components.Settings.SettingsMain.partialRequestsEnabled": "부분 시리즈 요청 허용",
|
||||||
"components.Settings.SettingsMain.trustProxy": "프록시 지원 활성화",
|
"components.Settings.SettingsMain.trustProxy": "프록시 지원 활성화",
|
||||||
"components.Settings.SettingsMain.trustProxyTip": "프록시 뒤에서 클라이언트 IP 주소를 정확하게 등록하도록 Overseerr에 허용",
|
"components.Settings.SettingsMain.trustProxyTip": "프록시 뒤에서 클라이언트 IP 주소를 정확하게 등록하도록 Jellyseerr에 허용",
|
||||||
"components.Settings.SettingsUsers.localLoginTip": "사용자가 Plex OAuth 대신 이메일 주소와 암호를 사용하여 로그인하도록 허용",
|
"components.Settings.SettingsUsers.localLoginTip": "사용자가 Plex OAuth 대신 이메일 주소와 암호를 사용하여 로그인하도록 허용",
|
||||||
"components.Settings.SettingsUsers.movieRequestLimitLabel": "전역 영화 요청 제한",
|
"components.Settings.SettingsUsers.movieRequestLimitLabel": "전역 영화 요청 제한",
|
||||||
"components.Settings.SettingsUsers.toastSettingsSuccess": "사용자 설정이 성공적으로 저장되었습니다!",
|
"components.Settings.SettingsUsers.toastSettingsSuccess": "사용자 설정이 성공적으로 저장되었습니다!",
|
||||||
@@ -1081,6 +1081,7 @@
|
|||||||
"components.Settings.Notifications.encryptionTip": "대부분의 경우 암시적 TLS는 465 포트를 사용하고 STARTTLS는 587 포트를 사용합니다",
|
"components.Settings.Notifications.encryptionTip": "대부분의 경우 암시적 TLS는 465 포트를 사용하고 STARTTLS는 587 포트를 사용합니다",
|
||||||
"components.Settings.SettingsAbout.Releases.versionChangelog": "{version} 변경 로그",
|
"components.Settings.SettingsAbout.Releases.versionChangelog": "{version} 변경 로그",
|
||||||
"components.Settings.SettingsAbout.supportoverseerr": "Overseerr 지원",
|
"components.Settings.SettingsAbout.supportoverseerr": "Overseerr 지원",
|
||||||
|
"components.Settings.SettingsAbout.supportjellyseerr": "Jellyseerr 지원",
|
||||||
"components.Settings.SettingsAbout.uptodate": "최신",
|
"components.Settings.SettingsAbout.uptodate": "최신",
|
||||||
"components.Settings.SettingsAbout.githubdiscussions": "GitHub 토론",
|
"components.Settings.SettingsAbout.githubdiscussions": "GitHub 토론",
|
||||||
"components.Settings.SettingsAbout.version": "버전",
|
"components.Settings.SettingsAbout.version": "버전",
|
||||||
@@ -1098,7 +1099,7 @@
|
|||||||
"components.Settings.SettingsLogs.extraData": "추가 데이터",
|
"components.Settings.SettingsLogs.extraData": "추가 데이터",
|
||||||
"components.Settings.SettingsLogs.time": "타임스탬프",
|
"components.Settings.SettingsLogs.time": "타임스탬프",
|
||||||
"components.Settings.SettingsMain.cacheImages": "이미지 캐싱 활성화",
|
"components.Settings.SettingsMain.cacheImages": "이미지 캐싱 활성화",
|
||||||
"components.Settings.SettingsMain.generalsettingsDescription": "Overseerr에 대한 전역 및 기본 설정을 구성합니다.",
|
"components.Settings.SettingsMain.generalsettingsDescription": "Jellyseerr에 대한 전역 및 기본 설정을 구성합니다.",
|
||||||
"components.Settings.SettingsLogs.viewdetails": "세부 정보 보기",
|
"components.Settings.SettingsLogs.viewdetails": "세부 정보 보기",
|
||||||
"components.Settings.SettingsMain.applicationurl": "애플리케이션 URL",
|
"components.Settings.SettingsMain.applicationurl": "애플리케이션 URL",
|
||||||
"components.Settings.SettingsMain.apikey": "API 키",
|
"components.Settings.SettingsMain.apikey": "API 키",
|
||||||
@@ -1138,7 +1139,7 @@
|
|||||||
"components.Settings.librariesRemaining": "남은 라이브러리: {count}",
|
"components.Settings.librariesRemaining": "남은 라이브러리: {count}",
|
||||||
"components.Settings.noDefault4kServer": "4K {serverType} 서버는 사용자가 4K {mediaType} 요청을 제출할 수 있도록 기본 설정되어야 합니다.",
|
"components.Settings.noDefault4kServer": "4K {serverType} 서버는 사용자가 4K {mediaType} 요청을 제출할 수 있도록 기본 설정되어야 합니다.",
|
||||||
"components.Settings.scan": "라이브러리 동기화",
|
"components.Settings.scan": "라이브러리 동기화",
|
||||||
"components.Settings.plexsettingsDescription": "Plex 서버의 설정을 구성하세요. Overseerr는 Plex 라이브러리를 스캔하여 콘텐츠의 사용 가능성을 판단합니다.",
|
"components.Settings.plexsettingsDescription": "Plex 서버의 설정을 구성하세요. Jellyseerr는 Plex 라이브러리를 스캔하여 콘텐츠의 사용 가능성을 판단합니다.",
|
||||||
"components.Settings.notificationsettings": "알림 설정",
|
"components.Settings.notificationsettings": "알림 설정",
|
||||||
"components.Settings.plexsettings": "Plex 설정",
|
"components.Settings.plexsettings": "Plex 설정",
|
||||||
"components.Settings.notificationAgentSettingsDescription": "알림 에이전트를 구성하고 활성화합니다.",
|
"components.Settings.notificationAgentSettingsDescription": "알림 에이전트를 구성하고 활성화합니다.",
|
||||||
@@ -1155,7 +1156,7 @@
|
|||||||
"components.Settings.webAppUrlTip": "사용자에게 \"호스팅된\" 웹 앱 대신 서버의 웹 앱으로 이동하도록 선택적으로 설정할 수 있습니다",
|
"components.Settings.webAppUrlTip": "사용자에게 \"호스팅된\" 웹 앱 대신 서버의 웹 앱으로 이동하도록 선택적으로 설정할 수 있습니다",
|
||||||
"components.Setup.setup": "설정",
|
"components.Setup.setup": "설정",
|
||||||
"components.Setup.tip": "팁",
|
"components.Setup.tip": "팁",
|
||||||
"components.Setup.welcome": "Overseerr에 오신 것을 환영합니다",
|
"components.Setup.welcome": "Jellyseerr에 오신 것을 환영합니다",
|
||||||
"components.StatusBadge.status4k": "4K {status}",
|
"components.StatusBadge.status4k": "4K {status}",
|
||||||
"components.StatusBadge.status": "{status}",
|
"components.StatusBadge.status": "{status}",
|
||||||
"components.TvDetails.play4konplex": "Plex에서 4K로 재생",
|
"components.TvDetails.play4konplex": "Plex에서 4K로 재생",
|
||||||
@@ -1226,7 +1227,7 @@
|
|||||||
"pages.errormessagewithcode": "{statusCode} - {error}",
|
"pages.errormessagewithcode": "{statusCode} - {error}",
|
||||||
"pages.serviceunavailable": "서비스를 사용할 수 없음",
|
"pages.serviceunavailable": "서비스를 사용할 수 없음",
|
||||||
"components.Settings.settingUpPlexDescription": "Plex를 설정하려면, 세부 정보를 수동으로 입력하거나 <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>에서 검색된 서버를 선택할 수 있습니다. 사용 가능한 서버 목록을 불러오려면 드롭다운 오른쪽에 있는 버튼을 누르세요.",
|
"components.Settings.settingUpPlexDescription": "Plex를 설정하려면, 세부 정보를 수동으로 입력하거나 <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>에서 검색된 서버를 선택할 수 있습니다. 사용 가능한 서버 목록을 불러오려면 드롭다운 오른쪽에 있는 버튼을 누르세요.",
|
||||||
"components.Settings.tautulliSettingsDescription": "선택적으로 Tautulli 서버의 설정을 구성하세요. Overseerr는 Tautulli로부터 Plex 미디어의 시청 기록 데이터를 불러옵니다.",
|
"components.Settings.tautulliSettingsDescription": "선택적으로 Tautulli 서버의 설정을 구성하세요. Jellyseerr는 Tautulli로부터 Plex 미디어의 시청 기록 데이터를 불러옵니다.",
|
||||||
"components.RequestBlock.requestdate": "요청 일자",
|
"components.RequestBlock.requestdate": "요청 일자",
|
||||||
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# 선택한 필터} other {# 선택한 필터}}",
|
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# 선택한 필터} other {# 선택한 필터}}",
|
||||||
"components.QuotaSelector.seasons": "{count, plural, one {시즌} other {시즌}}",
|
"components.QuotaSelector.seasons": "{count, plural, one {시즌} other {시즌}}",
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
"components.TvDetails.network": "{networkCount, plural, one {Netwerk} other {Netwerken}}",
|
"components.TvDetails.network": "{networkCount, plural, one {Netwerk} other {Netwerken}}",
|
||||||
"components.TvDetails.firstAirDate": "Datum eerste uitzending",
|
"components.TvDetails.firstAirDate": "Datum eerste uitzending",
|
||||||
"components.TvDetails.anime": "Anime",
|
"components.TvDetails.anime": "Anime",
|
||||||
"components.StatusChacker.reloadJellyseerr": "Herladen",
|
"components.StatusChacker.reloadOverseerr": "Herladen",
|
||||||
"components.StatusChacker.newversionavailable": "Toepassingsupdate",
|
"components.StatusChacker.newversionavailable": "Toepassingsupdate",
|
||||||
"components.StatusChacker.newversionDescription": "Jellyseerr is geüpdatet! Klik op de onderstaande knop om de pagina opnieuw te laden.",
|
"components.StatusChacker.newversionDescription": "Jellyseerr is geüpdatet! Klik op de onderstaande knop om de pagina opnieuw te laden.",
|
||||||
"components.Settings.toastSettingsSuccess": "Instellingen succesvol opgeslagen!",
|
"components.Settings.toastSettingsSuccess": "Instellingen succesvol opgeslagen!",
|
||||||
|
|||||||