Compare commits

..

57 Commits

Author SHA1 Message Date
fallenbagel
b72a4a5ad8 refactor(settings): better erorr logging when jellyfin connection test fails in settings page 2024-06-13 17:08:11 +05:00
fallenbagel
6a51ade676 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-06-13 17:03:01 +05:00
Fallenbagel
a9741fa36d fix(auth): improve login resilience with headerless fallback authentication (#814)
adds fallback to authenticate without headers to ensure and improve resilience across different
browsers and client configurations.
2024-06-13 11:16:07 +02:00
Fallenbagel
b5a069901a fix: bypass cache-able lookups when resolving localhost (#813)
* fix: bypass cache-able lookups when resolving localhost

* fix: bypass cacheable-lookup when resolving localhost

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-06-13 04:53:12 +05:00
Fallenbagel
9aeb3604e6 fix(auth): validation of ipv6/ipv4 (#812)
validation for ipv6 was sort of broken where for example `::1` was being sent as `1`, therefore,
logins were broken. This PR fixes it by using nodejs `net.isIPv4()` & `net.isIPv6` for ipv4 and ipv6
validation.

possibly related to and fixes #795
2024-06-12 18:50:00 +05:00
Fallenbagel
6eb88f8674 ci: temporarily disable snap release builds (#811) 2024-06-12 10:49:15 +05:00
Gauthier
46ee8a4ca1 fix(api): add DNS caching (#810)
fix #387 #657 #728
2024-06-12 02:56:10 +05:00
Gauthier
f52939e4cd fix: remove the settings button of media when useless (#809)
After the Media Availability Sync job rund on deleted media, the setting button is still visible
even if neither the media file nor the media request no longer exists. This PR hides this button
when it's no longer the case
2024-06-11 19:47:02 +05:00
Gauthier
d31a2c37e6 fix(jellyfinscanner): assign only 4k available badge for a 4k request instead of both badges (#805)
When you have a 4k server setup, and request a 4k item, when it becomes available it also sets the
normal item as available thus not allowing the user to request for the normal item
2024-06-11 17:58:48 +05:00
Gauthier
20863d4a8d fix: empty email in user settings (#807)
Email is mandatory for every user and required during the setup of Jellyseerr, but it is possible to
set it empty afterwards in the user settings. When the email is empty, users are not able to connect
to Jellyseer. This PR makes the email field mandatory in the user settings.

fix #803
2024-06-11 16:23:35 +05:00
fallenbagel
310d789bfb refactor: revert the changes brought to try and fix formatter
added back the rest of the cypress settings and removed cypress settings from .prettierignore
2024-06-01 06:17:40 +05:00
fallenbagel
f3641d4cab Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-06-01 06:10:55 +05:00
Fallenbagel
4757f1c3e5 Revert "ci: update format check command to ignore .prettierignore files (#787)" (#788)
This reverts commit 1f1ad72e9e.
2024-06-01 06:10:07 +05:00
fallenbagel
2a1aab5d24 ci: attempt at trying to get formatter to pass on cypress config json file 2024-06-01 06:02:46 +05:00
fallenbagel
6ea76ecf31 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-06-01 05:53:06 +05:00
Fallenbagel
1f1ad72e9e ci: update format check command to ignore .prettierignore files (#787)
This is to try and fix formatting issues on #773 on a file
that should be ignored.
2024-06-01 05:52:14 +05:00
fallenbagel
62f97b385f test(cypress): add only missing jobs to the cypress settings 2024-06-01 05:45:45 +05:00
fallenbagel
6613254d43 chore(prettier): ran formatter on cypress config to fix format check error
format check locally passes on this file. However, it fails during the github actions format check.
Therefore, json language features formatter was run instead of prettier to see if that fixes the
issue.
2024-06-01 05:29:15 +05:00
fallenbagel
8c4f37836f chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier 2024-06-01 05:24:54 +05:00
fallenbagel
2700694a99 test(cypress): fix cypress tests failing
cypress settings were lacking some of the jobs so when the startJobs() is called when the app
starts, it was failing to schedule the jobs where their cron timings were not specified in the
cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options
were added to keep cypress settings consistent with a normal user.
2024-06-01 05:08:24 +05:00
fallenbagel
ce04315899 refactor(settingsmigrator): settings migration handler and the migrations 2024-06-01 03:31:50 +05:00
fallenbagel
130bb298f7 refactor(settings): use migrator for dynamic settings migrations 2024-06-01 02:31:16 +05:00
fallenbagel
226d451adf fix(settings): merging the wrong settings source 2024-05-31 21:20:12 +05:00
fallenbagel
8384d411ba fix(settings): jellyfin hostname should be carried out if hostname exists 2024-05-31 21:12:02 +05:00
fallenbagel
e640ff6c2e fix(settings): jellyfin migrations replacing the rest of the settings 2024-05-31 20:48:59 +05:00
Fallenbagel
04b86c30e6 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-05-30 20:31:19 +05:00
allcontributors[bot]
c3ddc860b6 docs: add ThowZzy as a contributor for code (#779)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-29 00:32:43 +05:00
ThowZzy
2bd125d9a5 fix(auth): case-sensitive logins not updating authtokens (#778) 2024-05-28 23:42:26 +05:00
fallenbagel
fe8c781cba fix(auth): auth failing with jellyfin login is disabled 2024-05-28 19:51:48 +05:00
fallenbagel
43e0a29543 refactor(i18n): extract translation keys 2024-05-26 19:42:14 +05:00
fallenbagel
57336d8a75 refactor: clean up commented out code 2024-05-26 19:38:39 +05:00
fallenbagel
822a0768cf fix: store req.body jellyfin settings temporarily and store only if valid
This should fix the issue where settings are saved even if the url
was invalid. Now the settings will only be saved if the url is
valid. Sort of like a test connection.
2024-05-26 19:24:26 +05:00
fallenbagel
e34881064a refactor: remove console logs and use getHostname and ApiErrorCodes 2024-05-26 19:24:01 +05:00
fallenbagel
135155cd85 Merge remote-tracking branch 'origin/develop' into refactor-jellyfin-settings 2024-05-26 18:30:24 +05:00
Fallenbagel
7a5e8d69bf feat(settings): stores jellyfin/emby server name in the settings (#763)
Stores jellyfin/emby(?) server name in the settings file. This might come in handy in the future
once simultaneous multi-server sync is implemented.
2024-05-26 18:21:14 +05:00
Fallenbagel
650c339d74 fix(jellyfinapi): use external api class for jellyfin api requests (#762)
* refactor(jellyfinapi): use the external api class for jellyfin api requests

refactors jellyfin api requests to be handled by the external api
to be consistent with how other external api requests are made

related #728, related #387

* style: prettier formatted

* refactor(jellyfinapi): rename device in auth header as jellyseerr

* refactor(error): rename api error code generic to unknown

* refactor(errorcodes): consistent casing of error code enums
2024-05-25 15:44:36 +05:00
Fallenbagel
4ef5a3c7c5 style: ran prettier on snap yaml file (#774) 2024-05-25 06:10:19 +05:00
fallenbagel
68952a9239 refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation
This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl,
and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In
addition, this improves validation as validation can be applied seperately to them instead of as one
whole regex doing the work to validate the url.
UI was updated to reflect this.

BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip,
port, useSsl, and urlBase. However, migration of old settings to new settings should work
automatically.
2024-05-25 05:44:05 +05:00
allcontributors[bot]
a791b53953 docs: add Bretterteig as a contributor for translation (#772)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:19:22 +05:00
allcontributors[bot]
68467ced9d docs: add JoaquinOlivero as a contributor for code (#771)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:18:18 +05:00
allcontributors[bot]
296aee6338 docs: add Kara-Zor-El as a contributor for infra (#770)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:17:05 +05:00
allcontributors[bot]
0a4b38e50d docs: add gauthier-th as a contributor for code (#766)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:13:16 +05:00
Fallenbagel
bcc84d8551 ci: turn off edge snap builds temporarily (#765)
turns off edge snap builds temporarily and makes it manual
2024-05-24 21:15:13 +05:00
Joaquin Olivero
783fda9621 feat: add Latin American Spanish translation (#725)
#677

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-05-24 21:07:53 +05:00
THOMAS B
d765055da8 feat(auth): send real information on login (#470)
* feat(auth): send real ip on login

* feat(auth): send application name on login
2024-05-24 18:05:05 +02:00
Fallenbagel
fed66f0702 chore: replace github sponsor with buymeacoffee (#764) 2024-05-24 18:47:52 +05:00
Julian Behr
461202da75 refactor: updated german translations (#732)
* Updated german translations

* Consistant sort titles

---------

Co-authored-by: Julian <git@muellerjulian.email>
2024-05-24 15:40:34 +02:00
Gauthier
0bbcfdc4f9 fix(api): save user email on the first try (#760)
* fix(api): save user email on the first try

fix #227

* fix(api): remove todo

* fix(logging): handle media server connection refused error/toast (#748)

* fix(logging): handle media server connection refused error/toast

Properly log as connection refused if the jellyfin/emby server is unreachable. Previously it used to
throw a credentials error which lead to a lot of confusion

* refactor(i8n): extract translation keys

* refactor(auth): error message for a more consistent format

* refactor(auth/errors): use custom error types and error codes instead of abusing error messages

* refactor(i8n): replace connection refused translation key with invalidurl

* fix(error): combine auth and api error class into a single one called network error

* fix(error): use the new network error and network error codes in auth/api

* refactor(error): rename NetworkError to ApiError

---------

Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2024-05-23 23:56:11 +05:00
Fallenbagel
f486fb5e75 fix(logging): handle media server connection refused error/toast (#748)
* fix(logging): handle media server connection refused error/toast

Properly log as connection refused if the jellyfin/emby server is unreachable. Previously it used to
throw a credentials error which lead to a lot of confusion

* refactor(i8n): extract translation keys

* refactor(auth): error message for a more consistent format

* refactor(auth/errors): use custom error types and error codes instead of abusing error messages

* refactor(i8n): replace connection refused translation key with invalidurl

* fix(error): combine auth and api error class into a single one called network error

* fix(error): use the new network error and network error codes in auth/api

* refactor(error): rename NetworkError to ApiError
2024-05-23 19:34:31 +05:00
allcontributors[bot]
10082292e8 docs: add joshuaboniface as a contributor for code (#734)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-04-29 12:21:18 +05:00
Joshua M. Boniface
c0a0b9c8a8 fix: use UTF8 encoding for webhook JSON (#714) 2024-04-29 12:19:01 +05:00
Gauvino
d9d07c705a feat: add merge conflict labeler workflow (#719)
remove dash on label
2024-04-20 02:08:33 +05:00
Kara
0eea1090df fix(api): small errors on overseerr-api.yaml (#721) 2024-04-20 00:21:12 +05:00
Fallenbagel
cd0fa3e223 Revert "fix: disable seasonfolder option in sonarr for jellyfin/Emby users" (#718)
This reverts commit 8ec8f2ac57.
Disabling seasonfolder is no longer needed as we now allow
virtualFolders from jellyfin api.
2024-04-17 16:07:02 +05:00
Gauvino
9c68616343 Update action (#717)
- Update action version to latest (remove all warnings)
 - Update nodejs version on action
 - Update ubuntu to latest on support action
2024-04-16 02:41:08 +05:00
Fallenbagel
0c2713213c feat: jellyseerr makeover (#715) 2024-04-16 00:22:12 +05:00
Fallenbagel
3856061fe1 fix(jellyfinapi): refactors jellyfin library sync to support automatic grouping and collections (#700)
* fix(jellyfinapi): refactors jellyfin library sync to support automatic grouping and collections

Previously, #450 added support for automatic library grouping. However, some users reported that
they were getting a 401 when using custom authentication such as LDAP. Therefore, that PR was
reverted (#524). This PR adds back the support for automatic library grouping for jellyfin
authentication users using the endpoint `/Library/MediaFolders` and fallsback to User views endpoint
if they're unable to sync the libraries (some, not all LDAP users had issues. Some reported that it
worked despite having custom authentication). Once it falls back to user views endpoint for syncing,
now it will detect if automatic grouping is enabled giving a warning that its not supported when
using some custom authentication methods. This PR also fixed collection syncing by expanding the
boxsets when syncing.

fix #256, fix #489, re #450, #524, fix #515, fix #474, fix #473

* refactor(i18n): adds the suffix "jellyfin" to jellyfin library sync message keys

* refactor(i18n): extract translation keys

* refactor: remove console logs

* refactor: remove more console logs

* refactor: apply review suggestions

* chore: fix prettier failing on .github file
2024-04-16 00:04:11 +05:00
106 changed files with 3421 additions and 1979 deletions

View File

@@ -322,6 +322,60 @@
"contributions": [
"doc"
]
},
{
"login": "joshuaboniface",
"name": "Joshua M. Boniface",
"avatar_url": "https://avatars.githubusercontent.com/u/4031396?v=4",
"profile": "https://www.boniface.me",
"contributions": [
"code"
]
},
{
"login": "gauthier-th",
"name": "Gauthier",
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
"profile": "https://gauthierth.fr/",
"contributions": [
"code"
]
},
{
"login": "Kara-Zor-El",
"name": "Kara",
"avatar_url": "https://avatars.githubusercontent.com/u/69772087?v=4",
"profile": "https://github.com/Kara-Zor-El",
"contributions": [
"infra"
]
},
{
"login": "JoaquinOlivero",
"name": "Joaquin Olivero",
"avatar_url": "https://avatars.githubusercontent.com/u/66050823?v=4",
"profile": "https://joaquinolivero.com",
"contributions": [
"code"
]
},
{
"login": "Bretterteig",
"name": "Julian Behr",
"avatar_url": "https://avatars.githubusercontent.com/u/47298401?v=4",
"profile": "https://github.com/Bretterteig",
"contributions": [
"translation"
]
},
{
"login": "ThowZzy",
"name": "ThowZzy",
"avatar_url": "https://avatars.githubusercontent.com/u/61882536?v=4",
"profile": "https://github.com/ThowZzy",
"contributions": [
"code"
]
}
]
}

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: [Fallenbagel]
buy_me_a_coffee: fallen.bagel

View File

@@ -16,7 +16,7 @@ jobs:
container: node:18.18-alpine
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install dependencies
env:
HUSKY: 0
@@ -34,18 +34,18 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -56,7 +56,7 @@ jobs:
env:
OWNER: ${{ github.repository_owner }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v2

26
.github/workflows/conflict_labeler.yml vendored Normal file
View 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 }}'

View File

@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Cypress run
uses: cypress-io/github-action@v4
uses: cypress-io/github-action@v6
with:
build: yarn cypress:build
start: yarn start

View File

@@ -11,21 +11,21 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Get the version
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile

View File

@@ -10,19 +10,19 @@ jobs:
HUSKY: 0
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 16
node-version: 18
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -35,60 +35,60 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
architecture:
- amd64
- arm64
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Switch to main branch
run: git checkout main
- name: Pull latest changes
run: git pull
- name: Prepare
id: prepare
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build
with:
architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v2
with:
name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
with:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
# build-snap:
# name: Build Snap Package (${{ matrix.architecture }})
# needs: semantic-release
# runs-on: ubuntu-22.04
# strategy:
# fail-fast: false
# matrix:
# architecture:
# - amd64
# - arm64
# - armhf
# steps:
# - name: Checkout Code
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Switch to main branch
# run: git checkout main
# - name: Pull latest changes
# run: git pull
# - name: Prepare
# id: prepare
# run: |
# git fetch --prune --tags
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
# echo "RELEASE=stable" >> $GITHUB_OUTPUT
# else
# echo "RELEASE=edge" >> $GITHUB_OUTPUT
# fi
# - name: Set Up QEMU
# uses: docker/setup-qemu-action@v3
# with:
# image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
# - name: Build Snap Package
# uses: diddlesnaps/snapcraft-multiarch-action@v1
# id: build
# with:
# architecture: ${{ matrix.architecture }}
# - name: Upload Snap Package
# uses: actions/upload-artifact@v4
# with:
# name: jellyseerr-snap-package-${{ matrix.architecture }}
# path: ${{ steps.build.outputs.snap }}
# - name: Review Snap Package
# uses: diddlesnaps/snapcraft-review-tools-action@v1
# with:
# snap: ${{ steps.build.outputs.snap }}
# - name: Publish Snap Package
# uses: snapcore/action-publish@v1
# env:
# SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
# with:
# snap: ${{ steps.build.outputs.snap }}
# release: ${{ steps.prepare.outputs.RELEASE }}
discord:
name: Send Discord Notification

View File

@@ -1,9 +1,13 @@
name: Publish Snap
on:
push:
branches:
- develop
# turn off edge snap builds temporarily and make it manual
# on:
# push:
# branches:
# - develop
on: workflow_dispatch
jobs:
jobs:
@@ -12,7 +16,7 @@ jobs:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.10.0
uses: styfle/cancel-workflow-action@0.12.1
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
@@ -29,7 +33,7 @@ jobs:
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Prepare
id: prepare
run: |
@@ -40,7 +44,7 @@ jobs:
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Configure Git
run: git config --add safe.directory /data/parts/jellyseerr/src
- name: Build Snap Package
@@ -49,7 +53,7 @@ jobs:
with:
architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}

View File

@@ -6,9 +6,9 @@ on:
jobs:
support:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: dessant/support-requests@v2
- uses: dessant/support-requests@v4
with:
github-token: ${{ github.token }}
support-label: 'support'

View File

@@ -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="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-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 -->
**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="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -19,6 +19,7 @@
"region": "",
"originalLanguage": "",
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
"locale": "en"
},
@@ -37,6 +38,17 @@
],
"machineId": "test"
},
"jellyfin": {
"name": "",
"ip": "",
"port": 8096,
"useSsl": false,
"urlBase": "",
"externalHostname": "",
"jellyfinForgotPasswordUrl": "",
"libraries": [],
"serverId": ""
},
"tautulli": {},
"radarr": [],
"sonarr": [],
@@ -139,11 +151,26 @@
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"plex-watchlist-sync": {
"schedule": "0 */10 * * * *"
},
"availability-sync": {
"schedule": "0 0 5 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"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 * * *"
}
}
}

View File

@@ -2092,6 +2092,13 @@ paths:
application/json:
schema:
type: array
items:
type: object
properties:
username:
type: string
userId:
type: integer
/settings/jellyfin/sync:
get:
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.
tags:
- settings
parameters:
- in: path
name: sliderId
required: true
schema:
type: number
requestBody:
required: true
content:
@@ -3724,7 +3737,7 @@ paths:
results:
type: array
items:
$ref: '#/components/schemas/User'
$ref: '#/components/schemas/User'
post:
summary: Create new user
description: |

View File

@@ -44,6 +44,7 @@
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"cacheable-lookup": "^7.0.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -1,14 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import availabilitySync from '@server/lib/availabilitySync';
import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { ApiError } from '@server/types/error';
import { getAppVersion } from '@server/utils/appVersion';
export interface JellyfinUserResponse {
Name: string;
ServerId: string;
ServerName: string;
Id: string;
Configuration: {
GroupedFolders: string[];
};
Policy: {
IsAdministrator: boolean;
};
@@ -24,6 +29,13 @@ export interface JellyfinUserListResponse {
users: JellyfinUserResponse[];
}
interface JellyfinMediaFolder {
Name: string;
Id: string;
Type: string;
CollectionType: string;
}
export interface JellyfinLibrary {
type: 'show' | 'movie';
key: string;
@@ -80,48 +92,90 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string;
}
class JellyfinAPI {
class JellyfinAPI extends ExternalAPI {
private authToken?: string;
private userId?: string;
private jellyfinHost: string;
private axios: AxiosInstance;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
let authHeaderVal = '';
if (this.authToken) {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
} 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({
baseURL: this.jellyfinHost,
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
super(
jellyfinHost,
{},
{
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
}
public async login(
Username?: string,
Password?: string
Password?: string,
ClientIP?: string
): Promise<JellyfinLoginResponse> {
try {
const account = await this.axios.post<JellyfinLoginResponse>(
const authenticate = async (useHeaders: boolean) => {
const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName',
{
Username: Username,
Username,
Pw: Password,
}
},
{ headers }
);
return account.data;
};
try {
return await authenticate(true);
} 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;
}
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> {
try {
const account = await this.axios.get<JellyfinUserResponse>(
"/System/Info/Public'}"
const serverResponse = await this.get<JellyfinUserResponse>(
'/System/Info/Public'
);
return account.data.ServerName;
return serverResponse.ServerName;
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('girl idk');
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
public async getUsers(): Promise<JellyfinUserListResponse> {
try {
const account = await this.axios.get(`/Users`);
return { users: account.data };
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
return { users: userReponse };
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getUser(): Promise<JellyfinUserResponse> {
try {
const account = await this.axios.get<JellyfinUserResponse>(
const userReponse = await this.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}`
);
return account.data;
return userReponse;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getLibraries(): Promise<JellyfinLibrary[]> {
try {
// TODO: Try to fix automatic grouping without fucking up LDAP users
// const libraries = await this.axios.get<any>('/Library/VirtualFolders');
const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`);
const account = await this.axios.get<any>(
`/Users/${this.userId ?? 'Me'}/Views`
);
return this.mapLibraries(mediaFolderResponse.Items);
} 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(
(Item: any) => {
return (
Item.Type === 'CollectionFolder' &&
Item.CollectionType !== 'music' &&
Item.CollectionType !== 'books' &&
Item.CollectionType !== 'musicvideos' &&
Item.CollectionType !== 'homevideos'
);
}
).map((Item: any) => {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
return [];
}
}
}
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>{
key: Item.Id,
title: Item.Name,
@@ -200,24 +291,15 @@ class JellyfinAPI {
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[]> {
try {
const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
const libraryItemsResponse = await this.get<any>(
`/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'
);
} catch (e) {
@@ -225,23 +307,25 @@ class JellyfinAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
);
return contents.data;
return itemResponse;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ 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
): Promise<JellyfinLibraryItemExtended | undefined> {
try {
const contents = await this.axios.get<any>(
const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/${id}`
);
return contents.data;
return itemResponse;
} catch (e) {
if (availabilitySync.running) {
if (e.response && e.response.status === 500) {
return undefined;
}
}
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
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) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ 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
): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
);
return contents.data.Items.filter(
return episodeResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) {
@@ -299,7 +385,8 @@ class JellyfinAPI {
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
}

View 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',
}

View File

@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
Column,
@@ -211,15 +212,12 @@ class Media {
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const { serverId, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
: getHostname();
if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;

View File

@@ -23,19 +23,25 @@ import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import type CacheableLookupType from 'cacheable-lookup';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import { lookup } from 'dns';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import next from 'next';
import http from 'node:http';
import https from 'node:https';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
const _importDynamic = new Function('modulePath', 'return import(modulePath)');
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -46,6 +52,25 @@ const handle = app.getRequestHandler();
app
.prepare()
.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();
// Run migrations in production

View File

@@ -16,6 +16,7 @@ import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync {
public running = false;
@@ -84,7 +85,7 @@ class AvailabilitySync {
) {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
getHostname(),
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -141,7 +141,7 @@ class WebhookAgent
const payloadString = Buffer.from(
this.getSettings().options.jsonPayload,
'base64'
).toString('ascii');
).toString('utf8');
const parsedJSON = JSON.parse(JSON.parse(payloadString));

View File

@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash';
@@ -83,13 +84,17 @@ class JellyfinScanner {
}
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;
});
});
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;
});
});
@@ -590,8 +595,10 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
}
const hostname = getHostname();
this.jfClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
hostname,
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -1,10 +1,11 @@
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto';
import fs from 'fs';
import { merge } from 'lodash';
import path from 'path';
import webpush from 'web-push';
import { Permission } from './permissions';
export interface Library {
id: string;
@@ -38,7 +39,10 @@ export interface PlexSettings {
export interface JellyfinSettings {
name: string;
hostname: string;
ip: string;
port: number;
useSsl?: boolean;
urlBase?: string;
externalHostname?: string;
jellyfinForgotPasswordUrl?: string;
libraries: Library[];
@@ -130,7 +134,6 @@ interface FullPublicSettings extends PublicSettings {
region: string;
originalLanguage: string;
mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string;
@@ -274,7 +277,7 @@ export type JobId =
| 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings {
export interface AllSettings {
clientId: string;
vapidPublic: string;
vapidPrivate: string;
@@ -291,7 +294,7 @@ interface AllSettings {
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/settings.json`
: path.join(__dirname, '../../config/settings.json');
: path.join(__dirname, '../../../config/settings.json');
class Settings {
private data: AllSettings;
@@ -331,7 +334,10 @@ class Settings {
},
jellyfin: {
name: '',
hostname: '',
ip: '',
port: 8096,
useSsl: false,
urlBase: '',
externalHostname: '',
jellyfinForgotPasswordUrl: '',
libraries: [],
@@ -547,8 +553,6 @@ class Settings {
region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
@@ -637,7 +641,11 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
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();
}
return this;

View 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;

View 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;
};

View File

@@ -1,5 +1,6 @@
import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
@@ -9,9 +10,12 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
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 { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net';
const authRoutes = Router();
@@ -219,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string;
password?: string;
hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string;
};
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.jellyfin.hostname !== ''
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.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
.status(500)
.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.' });
}
try {
const hostname =
settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname
: body.hostname ?? '';
settings.jellyfin.ip !== ''
? getHostname()
: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin;
// 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'
);
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
let jellyfinHost =
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const ip = req.ip;
let clientIp;
if (ip) {
if (net.isIPv4(ip)) {
clientIp = ip;
} else if (net.isIPv6(ip)) {
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
}
}
const account = await jellyfinserver.login(
body.username,
body.password,
clientIp
);
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists
user = await userRepository.findOne({
where: { jellyfinUserId: account.User.Id },
@@ -278,7 +304,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
if (!user && !(await userRepository.count())) {
// Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) {
throw new Error('not_admin');
throw new ApiError(403, ApiErrorCode.NotAdmin);
}
logger.info(
@@ -306,15 +332,21 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
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.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.save();
startJobs();
await userRepository.save(user);
}
// User already exists, let's update their information
else if (body.username === user?.jellyfinUsername) {
else if (account.User.Id === user?.jellyfinUserId) {
logger.info(
`Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
@@ -412,43 +444,68 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
if (e.message === 'Unauthorized') {
logger.warn(
'Failed login attempt from user with incorrect Jellyfin credentials',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
password: '__REDACTED__',
},
}
);
return next({
status: 401,
message: 'Unauthorized',
});
} else if (e.message === 'not_admin') {
return next({
status: 403,
message: 'CREDENTIAL_ERROR_NOT_ADMIN',
});
} else if (e.message === 'add_email') {
return next({
status: 406,
message: 'CREDENTIAL_ERROR_ADD_EMAIL',
});
} else if (e.message === 'select_server_type') {
return next({
status: 406,
message: 'CREDENTIAL_ERROR_NO_SERVER_TYPE',
});
} else {
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
message: 'Something went wrong.',
});
switch (e.errorCode) {
case ApiErrorCode.InvalidUrl:
logger.error(
`The provided ${
process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin'
} is invalid or the server is not reachable.`,
{
label: 'Auth',
error: e.errorCode,
status: e.statusCode,
hostname: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
}),
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
case ApiErrorCode.InvalidCredentials:
logger.warn(
'Failed login attempt from user with incorrect Jellyfin credentials',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
password: '__REDACTED__',
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
case ApiErrorCode.NotAdmin:
logger.warn(
'Failed login attempt from user without admin permissions',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
default:
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
message: 'Something went wrong.',
});
}
}
});

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
@@ -24,8 +25,10 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
@@ -252,16 +255,64 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
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();
settings.jellyfin = merge(settings.jellyfin, req.body);
settings.save();
try {
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);
});
settingsRoutes.get('/jellyfin/library', async (req, res) => {
settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
const settings = getSettings();
if (req.query.sync) {
@@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
getHostname(),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -281,6 +332,22 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
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 existing = settings.jellyfin.libraries.find(
(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) => {
const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const { externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
: getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -326,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);

View File

@@ -275,7 +275,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
...webhookSettings.options,
jsonPayload: JSON.parse(
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
'ascii'
'utf8'
)
),
},

View File

@@ -20,6 +20,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
@@ -496,7 +497,6 @@ router.post(
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -504,15 +504,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();

View File

@@ -98,6 +98,7 @@ userSettingsRoutes.post<
}
user.username = req.body.username;
user.email = req.body.email ?? user.email;
// Update quota values only if the user has the correct permissions
if (
@@ -127,20 +128,19 @@ userSettingsRoutes.post<
user.settings.originalLanguage = req.body.originalLanguage;
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
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({
username: user.username,
discordId: user.settings.discordId,
locale: user.settings.locale,
region: user.settings.region,
originalLanguage: user.settings.originalLanguage,
watchlistSyncMovies: user.settings.watchlistSyncMovies,
watchlistSyncTv: user.settings.watchlistSyncTv,
email: user.email,
username: savedUser.username,
discordId: savedUser.settings?.discordId,
locale: savedUser.settings?.locale,
region: savedUser.settings?.region,
originalLanguage: savedUser.settings?.originalLanguage,
watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies,
watchlistSyncTv: savedUser.settings?.watchlistSyncTv,
email: savedUser.email,
});
} catch (e) {
next({ status: 500, message: e.message });

9
server/types/error.ts Normal file
View 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';
}
}

View 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;
};

View File

@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import useSettings from '@app/hooks/useSettings';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
@@ -13,7 +14,10 @@ import * as Yup from 'yup';
const messages = defineMessages({
username: 'Username',
password: 'Password',
host: '{mediaServerName} URL',
hostname: '{mediaServerName} URL',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
email: 'Email',
emailtooltip:
'Address does not need to be associated with your {mediaServerName} instance.',
@@ -23,9 +27,15 @@ const messages = defineMessages({
validationemailformat: 'Valid email required',
validationusernamerequired: 'Username 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.',
adminerror: 'You must use an admin account to sign in.',
credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
signingin: 'Signing in…',
signin: 'Sign In',
initialsigningin: 'Connecting…',
@@ -49,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
if (initial) {
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(
/^(?:(?:(?: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(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
.matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
@@ -73,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
};
return (
<Formik
initialValues={{
username: '',
password: '',
host: '',
hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '',
}}
validationSchema={LoginSchema}
@@ -87,18 +108,31 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
hostname: values.host,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
});
} 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(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: e.message == 'Request failed with status code 403'
? messages.adminerror
: messages.loginerror
),
intl.formatMessage(errorMessage, mediaServerFormatValues),
{
autoDismiss: true,
appearance: 'error',
@@ -109,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => (
{({
errors,
touched,
values,
setFieldValue,
isSubmitting,
isValid,
}) => (
<Form>
<div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label">
{intl.formatMessage(messages.host, mediaServerFormatValues)}
<div className="flex flex-col sm:flex-row sm:gap-4">
<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>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="host"
name="host"
type="text"
placeholder={intl.formatMessage(
messages.host,
mediaServerFormatValues
)}
inputMode="url"
id="urlBase"
name="urlBase"
placeholder={intl.formatMessage(messages.urlBase)}
/>
</div>
{errors.host && touched.host && (
<div className="error">{errors.host}</div>
{errors.urlBase && touched.urlBase && (
<div className="error">{errors.urlBase}</div>
)}
</div>
<label
htmlFor="email"
className="text-label"
style={{ display: 'inline-flex' }}
className="text-label inline-flex gap-1 align-middle"
>
{intl.formatMessage(messages.email)}
<span className="label-tip">
@@ -150,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</Tooltip>
</span>
</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">
<Field
id="email"

View File

@@ -434,33 +434,38 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</Button>
</Tooltip>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
<Tooltip content={intl.formatMessage(messages.managemovie)}>
<Button
buttonType="ghost"
onClick={() => setShowManager(true)}
className="relative ml-2 first:ml-0"
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<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>
)}
{hasPermission(Permission.MANAGE_REQUESTS) &&
data.mediaInfo &&
(data.mediaInfo.jellyfinMediaId ||
data.mediaInfo.jellyfinMediaId4k ||
data.mediaInfo.status !== MediaStatus.UNKNOWN ||
data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && (
<Tooltip content={intl.formatMessage(messages.managemovie)}>
<Button
buttonType="ghost"
onClick={() => setShowManager(true)}
className="relative ml-2 first:ml-0"
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<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 className="media-overview">

View File

@@ -4,6 +4,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import LibraryItem from '@app/components/Settings/LibraryItem';
import globalMessages from '@app/i18n/globalMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import type { JellyfinSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
@@ -32,9 +33,17 @@ const messages = defineMessages({
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.',
externalUrl: 'External URL',
internalUrl: 'Internal URL',
hostname: 'Hostname or IP Address',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
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',
syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan',
@@ -45,6 +54,12 @@ const messages = defineMessages({
librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start 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 {
@@ -60,6 +75,7 @@ interface SyncStatus {
currentLibrary?: Library;
libraries: Library[];
}
interface SettingsJellyfinProps {
showAdvancedSettings?: boolean;
onComplete?: () => void;
@@ -70,6 +86,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
showAdvancedSettings,
}) => {
const [isSyncing, setIsSyncing] = useState(false);
const toasts = useToasts();
const {
data,
@@ -87,18 +104,50 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
const { publicRuntimeConfig } = getConfig();
const JellyfinSettingsSchema = Yup.object().shape({
jellyfinExternalUrl: Yup.string().matches(
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
intl.formatMessage(messages.validationUrl)
),
jellyfinInternalUrl: Yup.string().matches(
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
intl.formatMessage(messages.validationUrl)
),
jellyfinForgotPasswordUrl: Yup.string().matches(
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
intl.formatMessage(messages.validationUrl)
),
hostname: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationHostnameRequired))
.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,
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number().when(['hostname'], {
is: (value: unknown) => !!value,
then: Yup.number()
.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 =
@@ -117,11 +166,43 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
params.enable = activeLibraries.join(',');
}
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
setIsSyncing(false);
revalidate();
try {
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
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 () => {
@@ -356,7 +437,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div>
<Formik
initialValues={{
jellyfinInternalUrl: data?.hostname || '',
hostname: data?.ip,
port: data?.port ?? 8096,
useSsl: data?.useSsl,
urlBase: data?.urlBase || '',
jellyfinExternalUrl: data?.externalHostname || '',
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
}}
@@ -364,7 +448,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
onSubmit={async (values) => {
try {
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,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
} as JellyfinSettings);
@@ -382,44 +469,127 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
}
);
} catch (e) {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
addToast(
intl.formatMessage(messages.invalidurlerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
}
}}
>
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
{({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
<div className="form-row">
<label htmlFor="jellyfinInternalUrl" className="text-label">
{intl.formatMessage(messages.internalUrl)}
<label htmlFor="hostname" className="text-label">
{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>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="jellyfinInternalUrl"
name="jellyfinInternalUrl"
id="urlBase"
name="urlBase"
/>
</div>
{errors.jellyfinInternalUrl &&
touched.jellyfinInternalUrl && (
<div className="error">
{errors.jellyfinInternalUrl}
</div>
{errors.urlBase &&
touched.urlBase &&
typeof errors.urlBase === 'string' && (
<div className="error">{errors.urlBase}</div>
)}
</div>
</div>

View File

@@ -1,10 +1,8 @@
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
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 { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -111,7 +109,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const settings = useSettings();
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
@@ -258,9 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
animeTags: sonarr?.animeTags ?? [],
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders:
sonarr?.enableSeasonFolders ??
settings.currentSettings.mediaServerType !== MediaServerType.PLEX,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch,
@@ -966,24 +961,11 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
>
{intl.formatMessage(messages.seasonfolders)}
</label>
<div
className={`form-input-area ${
settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN ||
settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'opacity-50'
: 'opacity-100'
}`}
>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSeasonFolders"
name="enableSeasonFolders"
disabled={
settings.currentSettings.mediaServerType !==
MediaServerType.PLEX
}
/>
</div>
</div>

View File

@@ -53,6 +53,8 @@ const messages = defineMessages({
discordId: 'Discord User ID',
discordIdTip:
'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',
plexwatchlistsyncmovies: 'Auto-Request Movies',
plexwatchlistsyncmoviestip:
@@ -88,6 +90,9 @@ const UserGeneralSettings = () => {
);
const UserGeneralSettingsSchema = Yup.object().shape({
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
discordId: Yup.string()
.nullable()
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),

View File

@@ -9,6 +9,7 @@ export type AvailableLocale =
| 'en'
| 'el'
| 'es'
| 'es-MX'
| 'fr'
| 'hr'
| 'hu'
@@ -59,6 +60,10 @@ export const availableLanguages: AvailableLanguageObject = {
code: 'es',
display: 'Español',
},
'es-MX': {
code: 'es-MX',
display: 'Español (Latinoamérica)',
},
fr: {
code: 'fr',
display: 'Français',

File diff suppressed because it is too large Load Diff

View File

@@ -219,17 +219,20 @@
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"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.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.email": "Email Address",
"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.host": "{mediaServerName} URL",
"components.Login.hostname": "{mediaServerName} URL",
"components.Login.initialsignin": "Connect",
"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.password": "Password",
"components.Login.port": "Port",
"components.Login.save": "Add",
"components.Login.saving": "Adding…",
"components.Login.signin": "Sign In",
@@ -239,9 +242,15 @@
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
"components.Login.signinwithplex": "Use your Plex account",
"components.Login.title": "Add Email",
"components.Login.urlBase": "URL Base",
"components.Login.username": "Username",
"components.Login.validationEmailFormat": "Invalid 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.validationemailrequired": "You must provide a valid email address",
"components.Login.validationhostformat": "Valid URL required",
@@ -752,8 +761,8 @@
"components.Settings.SettingsAbout.overseerrinformation": "About Jellyseerr",
"components.Settings.SettingsAbout.preferredmethod": "Preferred",
"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.supportoverseerr": "Support Overseerr",
"components.Settings.SettingsAbout.timezone": "Time Zone",
"components.Settings.SettingsAbout.totalmedia": "Total Media",
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
@@ -936,12 +945,16 @@
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
"components.Settings.externalUrl": "External URL",
"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.jellyfinForgotPasswordUrl": "Forgot Password URL",
"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.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
"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.jellyfinlibrariesDescription": "The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.",
"components.Settings.jellyfinsettings": "{mediaServerName} Settings",
@@ -1172,6 +1185,8 @@
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"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.discordId": "User ID",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",

View File

@@ -201,7 +201,7 @@
"components.Settings.SonarrModal.animerootfolder": "Carpeta raíz de anime",
"components.Settings.SonarrModal.animequalityprofile": "Perfil de calidad de anime",
"components.Settings.SettingsAbout.timezone": "Zona horaria",
"components.Settings.SettingsAbout.supportoverseerr": "Apoya a Overseerr",
"components.Settings.SettingsAbout.supportoverseerr": "Apoya a Jellyseerr",
"components.Settings.SettingsAbout.helppaycoffee": "Ayúdame invitándome a un café",
"components.Settings.SettingsAbout.Releases.viewongithub": "Ver en GitHub",
"components.Settings.SettingsAbout.Releases.viewchangelog": "Ver registro de cambios",
@@ -299,14 +299,14 @@
"components.RequestButton.viewrequest": "Ver Solicitud",
"components.RequestButton.requestmore4k": "Solicitar más en 4K",
"components.RequestButton.requestmore": "Solicitar más",
"components.RequestButton.declinerequests": "Rechazar {requestCount, plural, one {Request} other {{requestCount} Requests}}",
"components.RequestButton.declinerequests": "Rechazar {requestCount, plural, one {solicitud} other {{requestCount} solicitudes}}",
"components.RequestButton.declinerequest4k": "Rechazar Solicitud 4K",
"components.RequestButton.declinerequest": "Rechazar Solicitud",
"components.RequestButton.decline4krequests": "Rechazar {requestCount, plural, one {4K Request} other {{requestCount} 4K Requests}}",
"components.RequestButton.approverequests": "Aprobar {requestCount, plural, one {Request} other {{requestCount} Requests}}",
"components.RequestButton.decline4krequests": "Rechazar {requestCount, plural, one {solicitud en 4K} other {{requestCount} solicitudes en 4K}}",
"components.RequestButton.approverequests": "Aprobar {requestCount, plural, one {solicitud} other {{requestCount} solicitudes}}",
"components.RequestButton.approverequest4k": "Aprobar Solicitud 4K",
"components.RequestButton.approverequest": "Aprobar Solicitud",
"components.RequestButton.approve4krequests": "Aprobar {requestCount, plural, one {4K Request} other {{requestCount} 4K Requests}}",
"components.RequestButton.approve4krequests": "Aprobar {requestCount, plural, one {petición en 4K} other {requestCount} peticiones en 4K}}",
"components.RequestBlock.server": "Servidor de Destino",
"components.RequestBlock.rootfolder": "Carpeta Raíz",
"components.RequestBlock.profilechanged": "Perfil de Calidad",
@@ -656,7 +656,7 @@
"components.QuotaSelector.unlimited": "Ilimitadas",
"components.MovieDetails.originaltitle": "Título Original",
"components.LanguageSelector.originalLanguageDefault": "Todos los Idiomas",
"components.LanguageSelector.languageServerDefault": "({language}) por defecto",
"components.LanguageSelector.languageServerDefault": "({Language}) por defecto",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {cambio} other {cambios}} por detrás",
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Tu cuenta no tiene configurada una contraseña actualmente. Configure una contraseña a continuación para habilitar el acceso como \"usuario local\" utilizando tu dirección de email.",
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Esta cuenta de usuario no tiene configurada una contraseña actualmente. Configure una contraseña a continuación para habilitar el acceso como \"usuario local\"",
@@ -784,7 +784,7 @@
"components.DownloadBlock.estimatedtime": "Estimación de {time}",
"components.Settings.Notifications.encryptionOpportunisticTls": "Usa siempre STARTTLS",
"components.TvDetails.streamingproviders": "Emisión Actual en",
"components.UserProfile.UserSettings.UserGeneralSettings.languageDefault": "({language}) por defecto",
"components.UserProfile.UserSettings.UserGeneralSettings.languageDefault": "{{Language}} por defecto",
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "Para recibir notificaciones web push, Jellyseerr debe servirse mediante HTTPS.",
"components.Settings.Notifications.NotificationsWebhook.validationTypes": "Debes seleccionar, al menos, un tipo de notificación",
"components.Settings.Notifications.validationTypes": "Debes seleccionar, al menos, un tipo de notificación",
@@ -816,10 +816,10 @@
"components.Settings.Notifications.encryptionTip": "Normalmente, TLS Implícito usa el puerto 465 y STARTTLS usa el puerto 587",
"components.UserList.localLoginDisabled": "El ajuste para <strong>Habilitar el Inicio de Sesión Local</strong> está actualmente deshabilitado.",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Permisos iniciales asignados a nuevos usuarios",
"components.Settings.SettingsAbout.runningDevelop": "Estás utilizando la rama de <code>desarrollo</code> de Jellyseerr, la cual solo se recomienda para aquellos que contribuyen al desarrollo o al soporte de las pruebas de nuevos desarrollos.",
"components.Settings.SettingsAbout.runningDevelop": "Estás utilizando la rama de <code>develop</code> de Jellyseerr, la cual solo se recomienda para aquellos que contribuyen al desarrollo o al soporte de las pruebas de nuevos desarrollos.",
"components.StatusBadge.status": "{status}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Cada {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Cada {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Cada {jobScheduleMinutes, plural, one {minuto} other {{jobScheduleMinutes} minutos}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Cada {jobScheduleHours, plural, one {hora} other {{jobScheduleHours} horas}}",
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Algo fue mal al guardar la tarea programada.",
"components.Settings.SettingsJobsCache.editJobSchedule": "Modificar tarea programada",
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "Nueva frecuencia",
@@ -848,7 +848,7 @@
"components.IssueDetails.nocomments": "Sin comentarios.",
"components.IssueDetails.openedby": "#{issueId} abierta {relativeTime} por {username}",
"components.IssueDetails.openin4karr": "Abrir en {arr} 4K",
"components.IssueDetails.openinarr": "Abrir en {arr}",
"components.IssueDetails.openinarr": "Abierta en {arr}",
"components.IssueDetails.play4konplex": "Ver en 4K en {mediaServerName}",
"components.IssueDetails.playonplex": "Ver en {mediaServerName}",
"components.IssueDetails.problemepisode": "Episodio Afectado",
@@ -1193,7 +1193,7 @@
"components.RequestList.RequestItem.tvdbid": "Identificador de TheTVDB",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Limpieza de la caché de imágenes",
"components.Settings.SettingsJobsCache.imagecache": "Caché de imágenes",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Cada {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Cada {jobScheduleSeconds, plural, one {segundo} other {{jobScheduleSeconds} segundos}}",
"components.Settings.SettingsJobsCache.availability-sync": "Sincronización de la disponibilidad de medios",
"components.Discover.tmdbmoviestreamingservices": "Servicios de streaming de películas TMDB",
"components.Discover.tmdbtvstreamingservices": "Servicios de TV en streaming TMDB",
@@ -1204,111 +1204,5 @@
"i18n.collection": "Colección",
"components.Settings.RadarrModal.tagRequestsInfo": "Añadir automáticamente una etiqueta adicional con el nombre de usuario y el nombre para mostrar del solicitante",
"components.Settings.SonarrModal.tagRequestsInfo": "Añadir automáticamente una etiqueta adicional con el nombre de usuario y el nombre para mostrar del solicitante",
"components.MovieDetails.imdbuserscore": "Puntuación de los usuarios de IMDB",
"components.Layout.UserWarnings.passwordRequired": "Se requiere una contraseña.",
"components.Login.credentialerror": "El usuario o contraseña es incorrecto.",
"components.Login.host": "{mediaServerName} URL",
"components.Login.initialsignin": "Conectar",
"components.Login.initialsigningin": "Conectando…",
"components.Login.emailtooltip": "No es necesario asociar la dirección con su instancia de {mediaServerName}.",
"components.Login.saving": "Añadiendo…",
"components.Login.title": "Añadir Email",
"components.Login.username": "Nombre de usuario",
"components.Login.validationEmailFormat": "El email es inválido",
"components.Login.validationEmailRequired": "Debes proporcional un email",
"components.Login.validationemailformat": "Se requiere de un email válido",
"components.Login.validationhostformat": "Se requiere de una URL válida",
"components.Login.validationusernamerequired": "Se requiere un nombre de usuario",
"components.ManageSlideOver.manageModalRemoveMediaWarning": "* Esto eliminará de manera irreversible esta {mediaType} de {arr}, incluyendo todos los archivos.",
"i18n.open": "Abierto",
"components.MovieDetails.downloadstatus": "Estado de la descarga",
"components.MovieDetails.openradarr": "Abrir Película en Radarr",
"components.MovieDetails.openradarr4k": "Abrir Película 4K en Radarr",
"components.MovieDetails.play": "Reproducir en {mediaServerName}",
"components.MovieDetails.play4k": "Reproducir 4K en {mediaServerName}",
"components.NotificationTypeSelector.issueresolved": "Incidencia Resuelta",
"components.NotificationTypeSelector.userissuecommentDescription": "Notificame cuando haya nuevos comentarios en incidencias que haya abierto.",
"components.NotificationTypeSelector.userissuecreatedDescription": "Notificame cuando otros usuarios reporten incidencias.",
"components.PermissionEdit.viewissues": "Ver incidencias",
"components.PermissionEdit.manageissuesDescription": "Dar permiso para administrar incidencias.",
"components.PermissionEdit.viewissuesDescription": "Dar permiso para ver incidencias reportadas por otros usuarios.",
"components.Settings.Notifications.NotificationsPushover.sound": "Sonido de Notificacion",
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Escanear Añadidos Recientemente de Jellyfin",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Escaneo Completo de la libreria de Jellyfin",
"components.Settings.jellyfinSettings": "Ajustes de {mediaServerName}",
"components.Settings.jellyfinsettings": "Ajustes de {mediaServerName}",
"components.Settings.jellyfinSettingsFailure": "Algo salió mal al guardar la configuración de {mediaServerName}.",
"components.Settings.jellyfinSettingsSuccess": "¡La configuración de {mediaServerName} se guardó correctamente!",
"components.Settings.jellyfinlibraries": "Bibliotecas {mediaServerName}",
"components.Settings.jellyfinlibrariesDescription": "La biblioteca {mediaServerName} busca títulos. Haga clic en el botón a continuación si no aparece ninguna biblioteca.",
"components.Settings.manualscanDescriptionJellyfin": "Normalmente, esto sólo se ejecutará una vez cada 24 horas. Jellyseerr comprobará de forma más agresiva los añadidos recientemente de su servidor {mediaServerName}. ¡Si es la primera vez que configura Jellyseerr, se recomienda un escaneo manual completo de la biblioteca!",
"components.Settings.save": "Guardar Cambios",
"components.Settings.saving": "Guardando…",
"components.Settings.syncing": "Sincronizando",
"components.Settings.timeout": "Tiempo agotado",
"components.Setup.signinWithPlex": "Usa tu cuenta de Plex",
"components.Setup.configuremediaserver": "Configurar servidor multimedia",
"components.TitleCard.addToWatchList": "Añadir a lista de seguimiento",
"components.TitleCard.watchlistCancel": "Lista de seguimiento para <strong>{title}</strong> cancelada.",
"components.TitleCard.watchlistError": "Algo salió mal, intenta de nuevo.",
"components.TitleCard.watchlistSuccess": "<strong>{title}</strong> añadido correctamente a la lista de seguimiento!",
"components.TvDetails.play": "Reproducir en {mediaServerName}",
"components.UserList.importfromJellyfin": "Importar Usuarios de {mediaServerName}",
"components.UserList.mediaServerUser": "Usuario de {mediaServerName}",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} importado correctamente!",
"components.UserList.importfromJellyfinerror": "Se produjo un error al importar usuarios de {mediaServerName}.",
"components.UserProfile.UserSettings.UserGeneralSettings.save": "Guardar Cambios",
"components.UserProfile.UserSettings.UserGeneralSettings.email": "Email",
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Dispositivo Predeterminado",
"components.UserProfile.UserSettings.UserNotificationSettings.sound": "Sonido de Notificacion",
"components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingsfailed": "Fallo al guardar los ajustes de la notificación Pushbullet.",
"components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingssaved": "¡Los ajustes de notificación Pushbullet se han guardado con éxito!",
"components.UserProfile.UserSettings.UserNotificationSettings.pushoverApplicationToken": "Token de aplicación API",
"components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKey": "Clave de usuario o grupo",
"components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKeyTip": "Tu <UsersGroupsLink>identificador de usuario o grupo</UsersGroupsLink> de 30 caracteres",
"components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingssaved": "¡Se han guardado los ajustes de notificación de Pushover!",
"components.UserProfile.UserSettings.UserNotificationSettings.pushoverApplicationTokenTip": "<ApplicationRegistrationLink>Registrar una aplicación</ApplicationRegistrationLink> para usar con {applicationTitle}",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "Debes proporcionar una clave de usuario o grupo válida",
"components.Login.validationhostrequired": "{mediaServerName} URL requerida",
"i18n.resolved": "Resuelto",
"components.UserList.importfrommediaserver": "Importar Usuarios de {mediaServerName}",
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Dispositivo Predeterminado",
"components.ManageSlideOver.removearr": "Eliminar de {arr}",
"components.NotificationTypeSelector.issuereopenedDescription": "Enviar notificación cuando se reabran incidencias.",
"components.Layout.UserWarnings.emailRequired": "Se requiere una dirección de email.",
"components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessToken": "Token de Acceso",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "Debes proporcionar un token de aplicación válido",
"components.Settings.syncJellyfin": "Sincronizar Bibliotecas",
"components.Layout.UserWarnings.emailInvalid": "La dirección de correo es inválida.",
"components.Login.description": "Como es la primera vez que inicias sesión en {applicationName}, es necesario añadir una dirección de email válida.",
"components.Login.save": "Añadir",
"components.NotificationTypeSelector.issueresolvedDescription": "Enviar notificación cuando se resuelvan incidencias.",
"components.PermissionEdit.manageissues": "Administrar incidencias",
"components.PermissionEdit.createissues": "Notificar incidencia",
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Guardando…",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "Debes indicar un token de acceso",
"components.Login.signinwithjellyfin": "Utiliza tu cuenta de {mediaServerName}",
"components.ManageSlideOver.removearr4k": "Eliminar de {arr} 4K",
"components.Settings.internalUrl": "URL Interna",
"components.TvDetails.play4k": "Reproducir 4K en {mediaServerName}",
"components.Setup.signin": "Iniciar Sesión",
"components.Setup.signinWithJellyfin": "Utiliza tu cuenta de {mediaServerName}",
"components.UserList.noJellyfinuserstoimport": "No hay usuarios de {mediaServerName} que importar.",
"components.UserProfile.UserSettings.UserGeneralSettings.mediaServerUser": "Usuario de {mediaServerName}",
"components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessTokenTip": "Crea un token desde tu <PushbulletSettingsLink>Opciones de Cuenta</PushbulletSettingsLink>",
"components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingsfailed": "No se pudo guardar la configuración de notificaciones de Pushover.",
"components.NotificationTypeSelector.userissuereopenedDescription": "Recibir notificaciones cuando se vuelvan a abrir incidencias que haya reportado.",
"components.NotificationTypeSelector.userissueresolvedDescription": "Reciba notificaciones cuando se resuelvan las incidencias que haya reportado.",
"components.PermissionEdit.createissuesDescription": "Dar permiso para informar incidencias.",
"components.Settings.SettingsAbout.supportjellyseerr": "Apoya a Jellyseerr",
"components.Settings.Notifications.userEmailRequired": "Requerir email de usuario",
"components.Settings.SonarrModal.animeSeriesType": "Tipo de Serie Anime",
"components.Settings.SonarrModal.seriesType": "Tipo Serie",
"components.Settings.jellyfinSettingsDescription": "Opcionalmente, configure los puntos finales internos y externos para su servidor {mediaServerName}. En la mayoría de los casos, la URL externa es diferente a la URL interna. También se puede configurar una URL de restablecimiento de contraseña personalizada para el inicio de sesión de {mediaServerName}, en caso de que desee redirigir a una página de restablecimiento de contraseña diferente.",
"components.Settings.jellyfinsettingsDescription": "Configure los ajustes para su servidor {mediaServerName}. {mediaServerName} escanea sus bibliotecas de {mediaServerName} para ver qué contenido está disponible.",
"components.Settings.manualscanJellyfin": "Escanear Libreria Manualmente",
"components.TitleCard.watchlistDeleted": "<strong>{title}</strong> Eliminado correctamente de la lista de seguimiento!",
"components.UserList.newJellyfinsigninenabled": "La configuración <strong>Habilitar nuevo inicio de sesión de {mediaServerName}</strong> está actualmente habilitada. Los usuarios de {mediaServerName} con acceso a la biblioteca no necesitan ser importados para poder iniciar sesión.",
"components.UserProfile.localWatchlist": "Lista de seguimiento de {username}"
"components.MovieDetails.imdbuserscore": "Puntuación de los usuarios de IMDB"
}

1205
src/i18n/locale/es_MX.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@
"components.Discover.DiscoverTvGenre.genreSeries": "Séries {genre}",
"components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Séries",
"components.Discover.DiscoverTvLanguage.languageSeries": "Séries en {language}",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Votre watchlist",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Votre watchlist Plex",
"components.Discover.DiscoverWatchlist.watchlist": "Watchlist Plex",
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Filtre actif} other {# Filtres actifs}}",
"components.Discover.FilterSlideover.clearfilters": "Effacer les filtres actifs",
@@ -1251,71 +1251,5 @@
"components.Settings.SonarrModal.tagRequests": "Tager les demandes",
"components.Settings.SonarrModal.tagRequestsInfo": "Ajouter automatiquement un tag supplémentaire avec l'ID utilisateur et le nom d'affichage du demandeur",
"i18n.collection": "Collection",
"components.Settings.RadarrModal.tagRequestsInfo": "Ajouter automatiquement un tag supplémentaire avec l'ID utilisateur et le nom d'affichage du demandeur",
"components.IssueModal.issueVideo": "Vidéo",
"components.Settings.Notifications.NotificationsPushover.sound": "Son de notification",
"components.Settings.jellyfinSettings": "Paramètres pour {mediaServerName}",
"components.Settings.jellyfinSettingsFailure": "Une erreur est survenue lors de l'enregistrement des paramètres pour {mediaServerName}.",
"components.Settings.jellyfinSettingsSuccess": "Les paramètres pour {mediaServerName} ont été enregistrés avec succès!",
"components.Settings.jellyfinlibraries": "Bibliothèques {mediaServerName}",
"components.Settings.jellyfinlibrariesDescription": "Les bibliothèques de {mediaServerName} sont en cours d'analyze. Cliquez sur le bouton ci-dessous si aucune bibliothèque n'est répertoriée.",
"components.Settings.jellyfinsettings": "Paramètres pour {mediaServerName}",
"components.Settings.manualscanJellyfin": "Analyse manuelle de la bibliothèque",
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
"components.Settings.save": "Enregistrer les modifications",
"components.Settings.saving": "Sauvegarde en cours…",
"components.Settings.syncing": "Synchronisation en cours",
"components.Setup.signin": "Se connecter",
"components.Setup.signinWithPlex": "Utilisez votre compte Plex",
"components.StatusBadge.managemedia": "Gérer {mediaType}",
"components.StatusBadge.openinarr": "Ouvrir dans {arr}",
"components.StatusBadge.playonplex": "Lire sur {mediaServerName}",
"components.TitleCard.addToWatchList": "Ajouter à votre watchlist",
"components.TitleCard.watchlistCancel": "Watchlist pour <strong>{title}</strong> annulée.",
"components.TitleCard.watchlistError": "Une erreur est survenue. Veuillez réessayer.",
"components.TitleCard.watchlistSuccess": "<strong>{title}</strong> a été ajouté à votre watchlist avec succès !",
"components.TvDetails.Season.somethingwentwrong": "Une erreur est survenue lors de la récupération des données de la saison.",
"components.TvDetails.manageseries": "Gérer les séries",
"components.TvDetails.play": "Jouer sur {mediaServerName}",
"components.TvDetails.play4k": "Jouer en 4K sur {mediaServerName}",
"components.TvDetails.rtcriticsscore": "Tomatometer sur Rotten Tomatoes",
"components.TvDetails.seasonnumber": "Saison {seasonNumber}",
"components.TvDetails.seasonstitle": "Saisons",
"components.TvDetails.status4k": "{status} 4K",
"components.TvDetails.tmdbuserscore": "Score utilisateur sur TMDB",
"components.UserList.importfromJellyfin": "Importer les utilisateurs de {mediaServerName}",
"components.UserList.importfromJellyfinerror": "Une erreur est survenue lors de l'importation des utilisateurs de {mediaServerName}.",
"components.UserList.importfrommediaserver": "Importer les utilisateurs de {mediaServerName}",
"components.UserList.noJellyfinuserstoimport": "Il n'y a aucun utilisateur à importer pour {mediaServerName}.",
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Sauvegarde en cours…",
"components.UserProfile.plexwatchlist": "Watchlist Plex",
"components.Settings.syncJellyfin": "Synchroniser les bibliothèques",
"components.TitleCard.watchlistDeleted": "<strong>{title}</strong> a été retiré de votre watchlist avec succès !",
"components.IssueModal.issueSubtitles": "Sous-titre",
"components.Login.emailtooltip": "L'adresse ne nécessite pas d'être associée avec votre instance {mediaServerName}.",
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Appareil par défaut",
"components.Settings.Notifications.userEmailRequired": "E-mail utilisateur requis",
"components.Settings.SettingsAbout.supportjellyseerr": "Soutenez Jellyseerr",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Scan complet des bibliothèques Jellyfin",
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Scan des ajouts récents aux bibliothèques Jellyfin",
"components.Settings.SonarrModal.animeSeriesType": "Type de série anime",
"components.Settings.SonarrModal.seriesType": "Type de série",
"components.Settings.internalUrl": "URL interne",
"components.Settings.jellyfinsettingsDescription": "Configurez les paramètres de votre serveur {mediaServerName}. {mediaServerName} analyse vos bibliothèques {mediaServerName} pour voir quel contenu est disponible.",
"components.Settings.jellyfinSettingsDescription": "Configurez facultativement les URL internes et externes pour votre serveur {mediaServerName}. Dans la plupart des cas, l'URL externe est différente de l'URL interne. Vous pouvez également définir une URL de réinitialisation de mot de passe personnalisée pour la connexion à {mediaServerName}, au cas où vous souhaiteriez rediriger vers une page de réinitialisation de mot de passe différente.",
"components.Settings.manualscanDescriptionJellyfin": "Normalement, cette tâche est executée qu'une fois toutes les 24 heures. Jellyseerr vérifiera plus agressivement les éléments récemment ajoutés à votre serveur {mediaServerName}. Si c'est la première fois que vous configurez Jellyseerr, une analyse complète manuelle de la bibliothèque est recommandée !",
"components.Settings.timeout": "Temps écoulé",
"components.Setup.configuremediaserver": "Configurer le serveur multimédia",
"components.TvDetails.rtaudiencescore": "Score de l'audience sur Rotten Tomatoes",
"components.UserProfile.UserSettings.UserGeneralSettings.mediaServerUser": "Utilisateur pour {mediaServerName}",
"components.Setup.signinWithJellyfin": "Utilisez votre compte {mediaServerName}",
"components.UserList.mediaServerUser": "Utilisateur de {mediaServerName}",
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# Épisode} other {# Épisodes}}",
"components.UserList.newJellyfinsigninenabled": "Le paramètre <strong>Activer la nouvelle connexion à {mediaServerName}</strong> est actuellement activé. Les utilisateurs de {mediaServerName} avec accès à la bibliothèque n'ont pas besoin d'être importés pour se connecter.",
"components.UserProfile.UserSettings.UserGeneralSettings.email": "E-mail",
"components.UserProfile.UserSettings.UserGeneralSettings.save": "Enregistrer les modifications",
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Appareil par défaut",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} importé(s) avec succès !",
"components.UserProfile.UserSettings.UserNotificationSettings.sound": "Son de notification",
"components.UserProfile.localWatchlist": "Watchlist de {username}"
"components.Settings.RadarrModal.tagRequestsInfo": "Ajouter automatiquement un tag supplémentaire avec l'ID utilisateur et le nom d'affichage du demandeur"
}

View File

@@ -135,67 +135,5 @@
"components.Login.loginerror": "משהו השתבש בזמן ההתחברות.",
"components.Login.password": "סיסמה",
"components.Login.signin": "התחברות",
"components.Login.validationpasswordrequired": "חובה לכתוב סיסמה",
"components.Discover.CreateSlider.editSlider": "ערוך סליידר",
"components.Discover.CreateSlider.editfail": "נכשלה עריכת סליידר.",
"components.Discover.CreateSlider.needresults": "אתה צריך לפחות תוצאה אחת.",
"components.Discover.CreateSlider.nooptions": "אין תוצאות.",
"components.Discover.CreateSlider.providetmdbgenreid": "ספק מזהה ז'אנר TMDB",
"components.Discover.CreateSlider.providetmdbkeywordid": "ספק מזהה מילת מפתח TMDB",
"components.Discover.CreateSlider.providetmdbsearch": "ספק שאילתת חיפוש",
"components.Discover.CreateSlider.providetmdbstudio": "ספק מזהה סטודיו TMDB",
"components.Discover.CreateSlider.searchGenres": "חפש ז'אנרים…",
"components.Discover.CreateSlider.searchKeywords": "חפש מילות מפתח…",
"components.Discover.CreateSlider.searchStudios": "חפש אולפנים…",
"components.Discover.CreateSlider.slidernameplaceholder": "שם הסליידר",
"components.Discover.CreateSlider.validationDatarequired": "עליך לספק ערך.",
"components.Discover.CreateSlider.validationTitlerequired": "עליך לספק כותר.",
"components.Discover.DiscoverMovieKeyword.keywordMovies": "סרטים {keywordTitle}",
"components.Discover.DiscoverMovies.discovermovies": "סרטים",
"components.Discover.DiscoverMovies.sortPopularityAsc": "פופולריות בסדר עולה",
"components.Discover.DiscoverMovies.sortPopularityDesc": "פופולריות בסדר יורד",
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "תאריך שחרור בסדר עולה",
"components.Discover.DiscoverMovies.sortReleaseDateDesc": "תאריך שחרור בסדר יורד",
"components.Discover.DiscoverMovies.sortTitleAsc": "כותר (א-ת, A-Z) עולה",
"components.Discover.DiscoverMovies.sortTitleDesc": "כותר (ת-א, A-Z) יורד",
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "דירוג TMDB בסדר עולה",
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "דירוג TMDB בסדר יורד",
"components.Discover.DiscoverSliderEdit.deletesuccess": "סליידר נמחק בהצלחה.",
"components.Discover.DiscoverSliderEdit.enable": "שנה נראות",
"components.Discover.DiscoverSliderEdit.remove": "הסר",
"components.Discover.DiscoverTv.activefilters": "{count, plural, one {פילטר אחד פעיל} other {# פילטרים פעילים}}",
"components.Discover.DiscoverTv.discovertv": "סדרה",
"components.Discover.CreateSlider.addfail": "נכשלה יצירת סליידר חדש.",
"components.Discover.DiscoverTv.sortPopularityAsc": "פופולריות בסדר עולה",
"components.Discover.DiscoverTv.sortPopularityDesc": "פופולריות בסדר יורד",
"components.Discover.DiscoverTv.sortTitleAsc": "כותר (א-ת, A-Z) עולה",
"components.Discover.DiscoverTv.sortTitleDesc": "כותר (א-ת, A-Z) יורד",
"components.Discover.DiscoverTv.sortTmdbRatingAsc": "דירוג TMDB בסדר עולה",
"components.Discover.DiscoverTvKeyword.keywordSeries": "סדרה {keywordTitle}",
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {פילטר אחד פעיל} other {# פילטרים פעילים}}",
"components.Discover.FilterSlideover.clearfilters": "נקה פילטרים פעילים",
"components.Discover.FilterSlideover.filters": "פילטרים",
"components.Discover.FilterSlideover.firstAirDate": "תאריך שידור ראשון",
"components.Discover.FilterSlideover.from": "מאת",
"components.Discover.FilterSlideover.keywords": "מילות מפתח",
"components.Discover.FilterSlideover.originalLanguage": "שפה מקורית",
"components.Discover.FilterSlideover.ratingText": "דירוג בין {minValue} ל {maxValue}",
"components.Discover.FilterSlideover.releaseDate": "תאריך שחרור",
"components.Discover.FilterSlideover.runtime": "זמן ריצה",
"components.Discover.FilterSlideover.streamingservices": "שירותי הזרמה",
"components.Discover.FilterSlideover.studio": "אולפן",
"components.Discover.FilterSlideover.tmdbuserscore": "דירוג משתמש TMDB",
"components.Discover.CreateSlider.addSlider": "הוסף סליידר",
"components.Discover.CreateSlider.addcustomslider": "צור סליידר מותאם",
"components.Discover.CreateSlider.addsuccess": "סליידר חדש נוצר בהצלחה ונשמרו הגדרות התאמה.",
"components.Discover.CreateSlider.providetmdbnetwork": "ספק מזהה רשת TMDB",
"components.Discover.DiscoverTv.sortFirstAirDateDesc": "תאריך שידור ראשון בסדר יורד",
"components.Discover.CreateSlider.starttyping": "התחל להזין כדי לחפש.",
"components.Discover.FilterSlideover.runtimeText": "זמן ריצה בין {minValue} ל {maxValue}",
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {פילטר פעיל} other {# פילטרים פעילים}}",
"components.Discover.DiscoverSliderEdit.deletefail": "כשל במחיקת סליידר.",
"components.Discover.DiscoverTv.sortFirstAirDateAsc": "תאריך שידור ראשון בסדר עולה",
"components.Discover.CreateSlider.editsuccess": "סליידר נערך ונשמר בהצלחה.",
"components.Discover.DiscoverTv.sortTmdbRatingDesc": "דירוג TMDB בסדר יורד",
"components.Discover.FilterSlideover.genres": "ז'אנרים"
"components.Login.validationpasswordrequired": "חובה לכתוב סיסמה"
}

View File

@@ -3,12 +3,12 @@
"components.Discover.discovertv": "Populaire series",
"components.Discover.popularmovies": "Populaire films",
"components.Discover.populartv": "Populaire series",
"components.Discover.recentlyAdded": "Onlangs toegevoegd",
"components.Discover.recentlyAdded": "Recent toegevoegd",
"components.Discover.recentrequests": "Recente verzoeken",
"components.Discover.trending": "Trending",
"components.Discover.upcoming": "Verwachte films",
"components.Discover.upcomingmovies": "Verwachte films",
"components.Layout.SearchInput.searchPlaceholder": "Films en series zoeken",
"components.Layout.SearchInput.searchPlaceholder": "Zoek films en series",
"components.Layout.Sidebar.dashboard": "Ontdekken",
"components.Layout.Sidebar.requests": "Verzoeken",
"components.Layout.Sidebar.settings": "Instellingen",
@@ -16,7 +16,7 @@
"components.Layout.UserDropdown.signout": "Uitloggen",
"components.MovieDetails.budget": "Budget",
"components.MovieDetails.cast": "Cast",
"components.MovieDetails.originallanguage": "Oorspronkelijke taal",
"components.MovieDetails.originallanguage": "Originele taal",
"components.MovieDetails.overview": "Overzicht",
"components.MovieDetails.overviewunavailable": "Overzicht niet beschikbaar.",
"components.MovieDetails.recommendations": "Aanbevelingen",
@@ -114,7 +114,7 @@
"components.Settings.hostname": "Hostnaam of IP-adres",
"components.Settings.librariesRemaining": "Resterende bibliotheken: {count}",
"components.Settings.manualscan": "Handmatige bibliotheekscan",
"components.Settings.manualscanDescription": "Normaliter wordt dit eenmaal per 24 uur uitgevoerd. Jellyseerr zal de lijst met onlangs toegevoegde media op je Plex-server vaker controleren. Als dit de eerste keer is dat je Jellyseerr instelt, wordt aanbevolen eenmalig een handmatige, volledige bibliotheekscan uit te voeren!",
"components.Settings.manualscanDescription": "Normaal wordt dit eens elke 24 uur uitgevoerd. Jellyseerr controleert de recent toegevoegde items van je Plex-server agressiever. Als je Plex voor de eerste keer configureert, is een eenmalige handmatige volledige bibliotheekscan aanbevolen!",
"components.Settings.menuAbout": "Over",
"components.Settings.menuGeneralSettings": "Algemeen",
"components.Settings.menuJobs": "Taken en cache",
@@ -127,7 +127,7 @@
"components.Settings.plexlibraries": "Plex-bibliotheken",
"components.Settings.plexlibrariesDescription": "De bibliotheken die Jellyseerr scant voor titels. Stel je Plex-verbinding in en sla ze op. Klik daarna op de onderstaande knop als er geen bibliotheken staan.",
"components.Settings.plexsettings": "Plex-instellingen",
"components.Settings.plexsettingsDescription": "Configureer de instellingen voor je Plex-server. Jellyseerr scant je Plex-bibliotheken om te zien welke inhoud beschikbaar is.",
"components.Settings.plexsettingsDescription": "Configureer de instellingen voor je Plex-server. Jellyseerr scant je Plex-bibliotheken om te zien welke content beschikbaar is.",
"components.Settings.port": "Poort",
"components.Settings.radarrsettings": "Radarr-instellingen",
"components.Settings.sonarrsettings": "Sonarr-instellingen",
@@ -137,12 +137,12 @@
"components.Setup.configureservices": "Diensten configureren",
"components.Setup.continue": "Doorgaan",
"components.Setup.finish": "Installatie voltooien",
"components.Setup.finishing": "Voltooien…",
"components.Setup.finishing": "Bezig met voltooien…",
"components.Setup.loginwithplex": "Inloggen met Plex",
"components.Setup.signinMessage": "Ga aan de slag door je aan te melden",
"components.Setup.signinMessage": "Ga aan de slag door in te loggen met je Plex-account",
"components.Setup.welcome": "Welkom bij Jellyseerr",
"components.TvDetails.cast": "Cast",
"components.TvDetails.originallanguage": "Oorspronkelijke taal",
"components.TvDetails.originallanguage": "Originele taal",
"components.TvDetails.overview": "Overzicht",
"components.TvDetails.overviewunavailable": "Overzicht niet beschikbaar.",
"components.TvDetails.recommendations": "Aanbevelingen",
@@ -164,7 +164,7 @@
"i18n.movies": "Films",
"i18n.partiallyavailable": "Deels beschikbaar",
"i18n.pending": "In behandeling",
"i18n.processing": "Verwerken",
"i18n.processing": "Bezig met verwerken",
"i18n.tvshows": "Series",
"i18n.unavailable": "Niet beschikbaar",
"pages.oops": "Oeps",
@@ -187,14 +187,14 @@
"components.Setup.tip": "Tip",
"components.Settings.SonarrModal.testFirstRootFolders": "Test verbinding om hoofdmappen te laden",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test verbinding om kwaliteitsprofielen te laden",
"components.Settings.SonarrModal.loadingrootfolders": "Hoofdmappen laden…",
"components.Settings.SonarrModal.loadingprofiles": "Kwaliteitsprofielen laden…",
"components.Settings.SonarrModal.loadingrootfolders": "Bezig met laden van hoofdmappen…",
"components.Settings.SonarrModal.loadingprofiles": "Bezig met laden van kwaliteitsprofielen…",
"components.Settings.SettingsAbout.gettingsupport": "Ondersteuning krijgen",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Je moet een minimale beschikbaarheid selecteren",
"components.Settings.RadarrModal.testFirstRootFolders": "Test verbinding om hoofdmappen te laden",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test verbinding om kwaliteitsprofielen te laden",
"components.Settings.RadarrModal.loadingrootfolders": "Hoofdmappen laden…",
"components.Settings.RadarrModal.loadingprofiles": "Kwaliteitsprofielen laden…",
"components.Settings.RadarrModal.loadingrootfolders": "Bezig met laden van hoofdmappen…",
"components.Settings.RadarrModal.loadingprofiles": "Bezig met laden van kwaliteitsprofielen…",
"components.Settings.SettingsAbout.Releases.releasedataMissing": "Versiegegevens zijn momenteel niet beschikbaar.",
"components.Settings.SettingsAbout.Releases.latestversion": "Nieuwste",
"components.Settings.SettingsAbout.Releases.currentversion": "Huidig",
@@ -208,7 +208,7 @@
"i18n.retry": "Opnieuw proberen",
"i18n.requested": "Aangevraagd",
"i18n.failed": "Mislukt",
"i18n.deleting": "Verwijderen…",
"i18n.deleting": "Bezig met verwijderen…",
"i18n.close": "Sluiten",
"components.UserList.userdeleteerror": "Er ging iets mis bij het verwijderen van de gebruiker.",
"components.UserList.userdeleted": "Gebruiker succesvol verwijderd!",
@@ -223,7 +223,7 @@
"components.TvDetails.network": "{networkCount, plural, one {Netwerk} other {Netwerken}}",
"components.TvDetails.firstAirDate": "Datum eerste uitzending",
"components.TvDetails.anime": "Anime",
"components.StatusChacker.reloadJellyseerr": "Herladen",
"components.StatusChacker.reloadOverseerr": "Herladen",
"components.StatusChacker.newversionavailable": "Toepassingsupdate",
"components.StatusChacker.newversionDescription": "Jellyseerr is geüpdatet! Klik op de onderstaande knop om de pagina opnieuw te laden.",
"components.Settings.toastSettingsSuccess": "Instellingen succesvol opgeslagen!",
@@ -303,10 +303,10 @@
"components.UserList.create": "Aanmaken",
"components.UserList.createlocaluser": "Lokale gebruiker aanmaken",
"components.UserList.usercreatedfailed": "Er ging iets mis bij het aanmaken van de gebruiker.",
"components.UserList.creating": "Aanmaken…",
"components.UserList.creating": "Bezig met aanmaken…",
"components.UserList.validationpasswordminchars": "Wachtwoord is te kort; moet minimaal 8 tekens bevatten",
"components.UserList.usercreatedsuccess": "Gebruiker succesvol aangemaakt!",
"components.UserList.passwordinfodescription": "Stel een applicatie-URL in en schakel e-mailmeldingen in om automatische wachtwoordgeneratie mogelijk te maken.",
"components.UserList.passwordinfodescription": "Configureer een applicatie-URL en schakel e-mailmeldingen in om automatische wachtwoordgeneratie mogelijk te maken.",
"components.UserList.password": "Wachtwoord",
"components.UserList.localuser": "Lokale gebruiker",
"components.UserList.email": "E-mailadres",
@@ -340,23 +340,23 @@
"components.RequestModal.SearchByNameModal.notvdbiddescription": "We kunnen deze serie niet automatisch matchen. Selecteer hieronder de juiste match.",
"components.Login.signinwithplex": "Plex-account gebruiken",
"components.Login.signinheader": "Log in om verder te gaan",
"components.Login.signingin": "Aanmelden…",
"components.Login.signingin": "Bezig met inloggen…",
"components.Login.signin": "Inloggen",
"components.Settings.notificationAgentSettingsDescription": "Meldingsagenten configureren en inschakelen.",
"components.PlexLoginButton.signinwithplex": "Inloggen",
"components.PlexLoginButton.signingin": "Aanmelden…",
"components.PlexLoginButton.signingin": "Bezig met inloggen…",
"components.PermissionEdit.advancedrequest": "Geavanceerde aanvragen",
"components.PermissionEdit.admin": "Beheerder",
"components.UserList.userssaved": "Gebruikersrechten succesvol opgeslagen!",
"components.Settings.toastPlexRefreshSuccess": "Serverlijst van Plex succesvol opgehaald!",
"components.Settings.toastPlexRefresh": "Bezig met serverlijst ophalen van Plex…",
"components.Settings.toastPlexConnecting": "Verbinden met Plex…",
"components.Settings.toastPlexConnecting": "Bezig met verbinden met Plex-server…",
"components.UserList.bulkedit": "Meerdere bewerken",
"components.Settings.toastPlexRefreshFailure": "Kan serverlijst van Plex niet ophalen.",
"components.Settings.toastPlexConnectingSuccess": "Succesvol verbonden met Plex-server!",
"components.Settings.toastPlexConnectingFailure": "Kan geen verbinding maken met Plex.",
"components.Settings.settingUpPlexDescription": "Om Plex in te stellen, kan je de gegevens handmatig invoeren of een server selecteren die is opgehaald van <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Druk op de knop rechts van de vervolgkeuzelijst om de lijst van beschikbare servers op te halen.",
"components.Settings.serverpresetRefreshing": "Servers ophalen…",
"components.Settings.serverpresetRefreshing": "Bezig met servers ophalen…",
"components.Settings.serverpresetManualMessage": "Handmatige configuratie",
"components.Settings.serverpresetLoad": "Klik op de knop om de beschikbare servers te laden",
"components.Settings.serverpreset": "Server",
@@ -431,7 +431,7 @@
"components.RequestModal.AdvancedRequester.requestas": "Aanvragen als",
"components.Discover.discover": "Ontdekken",
"components.Settings.validationApplicationTitle": "Je moet een toepassingstitel opgeven",
"components.AppDataWarning.dockerVolumeMissingDescription": "De volumekoppeling <code>{appDataPath}</code> is niet correct geconfigureerd. Alle gegevens zullen worden gewist wanneer de container wordt gestopt of opnieuw wordt gestart.",
"components.AppDataWarning.dockerVolumeMissingDescription": "De volumekoppeling <code>{appDataPath}</code> was niet correct geconfigureerd. Alle gegevens zullen worden gewist wanneer de container wordt gestopt of opnieuw wordt gestart.",
"components.Settings.validationApplicationUrlTrailingSlash": "URL mag niet eindigen op een schuine streep",
"components.Settings.validationApplicationUrl": "Je moet een geldige URL opgeven",
"components.Settings.SonarrModal.validationApplicationUrlTrailingSlash": "URL mag niet eindigen op een schuine streep",
@@ -520,7 +520,7 @@
"components.Layout.UserDropdown.myprofile": "Profiel",
"components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "Je moet een geldige gebruikers-ID opgeven",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "Het <FindDiscordIdLink>meercijferige ID-nummer</FindDiscordIdLink> van je gebruikersaccount",
"components.CollectionDetails.requestcollection4k": "Collectie aanvragen in 4K",
"components.CollectionDetails.requestcollection4k": "Collectie in 4K aanvragen",
"components.UserProfile.UserSettings.UserGeneralSettings.regionTip": "Inhoud filteren op regionale beschikbaarheid",
"components.UserProfile.UserSettings.UserGeneralSettings.region": "Regio van Ontdekken",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Inhoud filteren op oorspronkelijke taal",
@@ -546,7 +546,7 @@
"components.Settings.SettingsJobsCache.download-sync-reset": "Reset download sync",
"components.Settings.SettingsJobsCache.download-sync": "Synchronisatie downloads",
"components.TvDetails.seasons": "{seasonCount, plural, one {# seizoen} other {# seizoenen}}",
"i18n.loading": "Laden…",
"i18n.loading": "Bezig met laden…",
"components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "Je moet een geldige chat-ID opgeven",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "<TelegramBotLink>Een chat starten</TelegramBotLink>, <GetIdBotLink>@get_id_bot</GetIdBotLink> toevoegen en de opdracht <code>/my_id</code> geven",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "Chat-ID",
@@ -558,14 +558,14 @@
"components.Discover.DiscoverNetwork.networkSeries": "Series van {network}",
"components.Discover.DiscoverMovieGenre.genreMovies": "{genre} films",
"components.Setup.scanbackground": "Het scannen wordt op de achtergrond uitgevoerd. Je kunt in de tussentijd doorgaan met het installatieproces.",
"components.Settings.scanning": "Synchroniseren…",
"components.Settings.scanning": "Bezig met synchroniseren…",
"components.Settings.scan": "Bibliotheken synchroniseren",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr-scan",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr-scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex recent toegevoegde scan",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex volledige bibliotheekscan",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "volledige bibliotheekscan Jellyfin",
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Scan van 'onlangs toegevoegd' in Jellyfin",
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Jellyfin recent toegevoegde scan",
"components.Settings.Notifications.validationUrl": "Je moet een geldige URL opgeven",
"components.Settings.Notifications.botAvatarUrl": "URL bot-avatar",
"components.RequestList.RequestItem.requested": "Aangevraagd",
@@ -670,27 +670,27 @@
"components.QuotaSelector.unlimited": "Onbeperkt",
"i18n.view": "Bekijken",
"i18n.tvshow": "Serie",
"i18n.testing": "Testen…",
"i18n.testing": "Bezig met testen…",
"i18n.test": "Test",
"i18n.status": "Status",
"i18n.showingresults": "<strong>{from}</strong> tot <strong>{to}</strong> van de <strong>{total}</strong> resultaten worden weergegeven",
"i18n.saving": "Opslaan…",
"i18n.saving": "Bezig met opslaan…",
"i18n.save": "Wijzigingen opslaan",
"i18n.resultsperpage": "{pageSize} resultaten per pagina weergeven",
"i18n.requesting": "Aanvragen…",
"i18n.requesting": "Bezig met aanvragen…",
"i18n.request4k": "Aanvragen in 4K",
"i18n.previous": "Vorige",
"i18n.notrequested": "Niet aangevraagd",
"i18n.noresults": "Geen resultaten.",
"i18n.next": "Volgende",
"i18n.movie": "Film",
"i18n.canceling": "Annuleren…",
"i18n.canceling": "Bezig met annuleren…",
"i18n.back": "Terug",
"i18n.areyousure": "Weet je het zeker?",
"i18n.all": "Alle",
"components.RequestModal.QuotaDisplay.requiredquotaUser": "Deze gebruiker heeft nog minstens <strong>{seasons}</strong> {seasons, plural, one {seizoensverzoek} other {seizoensverzoeken}} nodig om deze serie aan te vragen.",
"components.TvDetails.originaltitle": "Oorspronkelijke titel",
"components.MovieDetails.originaltitle": "Oorspronkelijke titel",
"components.TvDetails.originaltitle": "Originele titel",
"components.MovieDetails.originaltitle": "Originele titel",
"components.LanguageSelector.originalLanguageDefault": "Alle talen",
"components.LanguageSelector.languageServerDefault": "Standaard ({language})",
"components.Settings.SonarrModal.testFirstTags": "Test de verbinding om labels te laden",
@@ -733,9 +733,9 @@
"components.RequestModal.pendingapproval": "Je verzoek is in afwachting van goedkeuring.",
"components.RequestList.RequestItem.cancelRequest": "Verzoek annuleren",
"components.NotificationTypeSelector.notificationTypes": "Meldingtypes",
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Er is voor jouw account momenteel geen wachtwoord ingesteld. Configureer hieronder een wachtwoord om in te kunnen loggen als een \"lokale gebruiker\" met je e-mailadres.",
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Er is voor jouw account momenteel geen wachtwoord ingesteld. Configureer hieronder een wachtwoord om in te kunnen loggen als een \"lokale gebruiker\" met uw e-mailadres.",
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Deze gebruikersaccount heeft momenteel geen wachtwoord ingesteld. Configureer hieronder een wachtwoord zodat deze account in staat is om zich aan te melden als een \"lokale gebruiker\".",
"components.Settings.serviceSettingsDescription": "Stel je {serverType}-server(s) hieronder in. Je kunt meerdere {serverType}-servers verbinden, maar slechts twee ervan kunnen als standaard worden gemarkeerd (één niet-4K en één 4K). Beheerders kunnen vóór goedkeuring de server aanpassen die voor nieuwe aanvragen gebruikt wordt.",
"components.Settings.serviceSettingsDescription": "Configureer je {serverType} server(s) hieronder. Je kunt meerdere {serverType} servers verbinden, maar slechts twee ervan kunnen als standaard worden gemarkeerd (één niet-4K en één 4K). Beheerders kunnen vóór de goedkeuring de server die gebruikt wordt om nieuwe aanvragen te verwerken aanpassen.",
"components.Settings.noDefaultServer": "Ten minste één {serverType} server moet als standaard worden gemarkeerd om {mediaType}verzoeken te kunnen verwerken.",
"components.Settings.noDefaultNon4kServer": "Als je slechts één enkele {serverType} server hebt voor zowel niet-4K als 4K-inhoud (of als je alleen 4K-inhoud downloadt), dan moet je {serverType} server <strong>NIET</strong> aangeduid worden als een 4K-server.",
"components.Settings.mediaTypeSeries": "serie",
@@ -747,7 +747,7 @@
"components.Layout.VersionStatus.outofdate": "Verouderd",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} achter",
"components.UserList.autogeneratepasswordTip": "Een door de server gegenereerd wachtwoord naar de gebruiker e-mailen",
"i18n.retrying": "Opnieuw proberen…",
"i18n.retrying": "Bezig met opnieuw proberen…",
"components.Settings.serverSecure": "veilig",
"components.UserList.usercreatedfailedexisting": "Het opgegeven e-mailadres wordt al gebruikt door een andere gebruiker.",
"components.RequestModal.edit": "Verzoek bewerken",
@@ -847,7 +847,7 @@
"components.NotificationTypeSelector.usermediadeclinedDescription": "Een melding ontvangen wanneer je mediaverzoeken worden geweigerd.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Een melding ontvangen wanneer je mediaverzoeken beschikbaar zijn.",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Een melding ontvangen wanneer andere gebruikers nieuwe mediaverzoeken indienen die automatisch worden goedgekeurd.",
"components.Settings.SettingsAbout.betawarning": "Dit is BETA-software. Functies kunnen kapot en/of instabiel zijn. Meld eventuele problemen op GitHub!",
"components.Settings.SettingsAbout.betawarning": "Dit is BETA software. Functies kunnen kapot en/of instabiel zijn. Meld eventuele problemen op GitHub!",
"components.Layout.LanguagePicker.displaylanguage": "Weergavetaal",
"components.MovieDetails.showmore": "Meer tonen",
"components.MovieDetails.showless": "Minder tonen",
@@ -971,7 +971,7 @@
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "Je moet een geldige gebruikers- of groepssleutel opgeven",
"components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingssaved": "Instellingen voor Pushbullet-meldingen succesvol opgeslagen!",
"components.IssueDetails.playonplex": "Afspelen op {mediaServerName}",
"components.IssueDetails.play4konplex": "Afspelen op {mediaServerName} in 4K",
"components.IssueDetails.play4konplex": "Afspelen in 4K op {mediaServerName}",
"components.IssueDetails.openin4karr": "Openen in 4K {arr}",
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {aflevering} other {afleveringen}}",
"components.IssueList.IssueItem.seasons": "{seasonCount, plural, one {seizoen} other {seizoenen}}",
@@ -982,7 +982,7 @@
"components.NotificationTypeSelector.adminissuereopenedDescription": "Ontvang een melding wanneer problemen door andere gebruikers opnieuw worden ingediend.",
"components.NotificationTypeSelector.issuereopenedDescription": "Stuur meldingen wanneer problemen opnieuw worden ingediend.",
"components.NotificationTypeSelector.userissuereopenedDescription": "Ontvang een bericht wanneer problemen die jij hebt gemeld, opnieuw worden ingediend.",
"components.RequestModal.requestseasons4k": "{seasonCount} {seasonCount, plural, one {seizoen} other {seizoenen}} aanvragen in 4K",
"components.RequestModal.requestseasons4k": "{seasonCount} {seasonCount, plural, one {seizoen} other {seizoenen}} in 4K aanvragen",
"components.RequestModal.requestmovies": "{count} {count, plural, one {film} other {films}} aanvragen",
"components.RequestModal.selectmovies": "Film(s) selecteren",
"components.MovieDetails.productioncountries": "Productie{countryCount, plural, one {land} other {landen}}",
@@ -998,7 +998,7 @@
"components.Settings.Notifications.NotificationsGotify.agentenabled": "Agent inschakelen",
"components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Instellingen voor meldingen Gotify succesvol opgeslagen!",
"components.Settings.Notifications.NotificationsGotify.token": "Toepassingstoken",
"i18n.importing": "Importeren…",
"i18n.importing": "Bezig met importeren…",
"components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Instellingen voor meldingen Gotify niet opgeslagen.",
"components.Settings.Notifications.NotificationsGotify.toastGotifyTestFailed": "Testmelding Gotify niet verzonden.",
"components.Settings.Notifications.NotificationsGotify.toastGotifyTestSuccess": "Testmelding Gotify verzonden!",
@@ -1012,7 +1012,7 @@
"components.UserList.newplexsigninenabled": "De instelling <strong>Nieuwe Plex-aanmelding inschakelen</strong> is momenteel ingeschakeld. Plex-gebruikers met bibliotheektoegang hoeven niet te worden geïmporteerd om in te loggen.",
"components.ManageSlideOver.manageModalAdvanced": "Geavanceerd",
"components.ManageSlideOver.alltime": "Altijd",
"components.ManageSlideOver.markallseasons4kavailable": "Alle seizoenen markeren als beschikbaar in 4K",
"components.ManageSlideOver.markallseasons4kavailable": "Alle seizoenen als beschikbaar in 4K markeren",
"components.ManageSlideOver.opentautulli": "In Tautulli openen",
"components.ManageSlideOver.pastdays": "Afgelopen {days, number} dagen",
"components.ManageSlideOver.playedby": "Afgespeeld door",
@@ -1046,8 +1046,8 @@
"components.UserProfile.emptywatchlist": "Media die zijn toegevoegd aan je <PlexWatchlistSupportLink>Plex Kijklijst</PlexWatchlistSupportLink> verschijnen hier.",
"components.MovieDetails.digitalrelease": "Digitale release",
"i18n.restartRequired": "Opnieuw opstarten vereist",
"components.PermissionEdit.viewrecentDescription": "Toestemming geven om de lijst met onlangs toegevoegde media weer te geven.",
"components.PermissionEdit.viewrecent": "Onlangs toegevoegd weergeven",
"components.PermissionEdit.viewrecentDescription": "Toestemming geven om de lijst met recent toegevoegde media te bekijken.",
"components.PermissionEdit.viewrecent": "Recent toegevoegd bekijken",
"components.Settings.deleteServer": "{serverType}-server verwijderen",
"components.StatusChecker.appUpdated": "{applicationTitle} bijgewerkt",
"components.RequestList.RequestItem.tmdbid": "TMDB ID",
@@ -1072,8 +1072,8 @@
"components.TvDetails.seasonnumber": "Seizoen {seasonNumber}",
"components.TvDetails.Season.somethingwentwrong": "Er ging iets mis bij het ophalen van de seizoensgegevens.",
"components.TvDetails.seasonstitle": "Seizoenen",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Jouw kijklijst",
"components.Discover.plexwatchlist": "Jouw kijklijst",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Je Plex-kijklijst",
"components.Discover.plexwatchlist": "Je Plex Kijklijst",
"components.MovieDetails.physicalrelease": "Fysieke release",
"components.PermissionEdit.autorequest": "Automatisch aanvragen",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Kijklijst synchroniseren",
@@ -1099,7 +1099,7 @@
"components.TvDetails.manageseries": "Serie beheren",
"components.MovieDetails.managemovie": "Film beheren",
"components.MovieDetails.reportissue": "Probleem melden",
"components.PermissionEdit.autorequestMoviesDescription": "Toestemming geven om niet-4K films in je Plex-kijklijst automatisch aan te vragen.",
"components.PermissionEdit.autorequestMoviesDescription": "Toestemming geven om niet-4K films in je Plex Kijklijst automatisch aan te vragen.",
"components.PermissionEdit.autorequestSeries": "Series automatisch aanvragen",
"components.PermissionEdit.autorequestMovies": "Films automatisch aanvragen",
"components.Settings.experimentalTooltip": "Deze instelling inschakelen, kan leiden tot onverwacht gedrag van de toepassing",
@@ -1152,8 +1152,8 @@
"components.Discover.DiscoverSliderEdit.remove": "Verwijderen",
"components.Discover.resetfailed": "Er is iets fout gegaan bij het resetten van de instellingen van Ontdekken.",
"components.Discover.PlexWatchlistSlider.emptywatchlist": "Media die zijn toegevoegd aan je <PlexWatchlistSupportLink>Plex Kijklijst</PlexWatchlistSupportLink> verschijnen hier.",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "Jouw kijklijst",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Onlangs toegevoegd",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "Je Plex Kijklijst",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Recent toegevoegd",
"components.Discover.networks": "Netwerken",
"components.Discover.CreateSlider.searchStudios": "Studio's zoeken…",
"components.Discover.CreateSlider.starttyping": "Begin met typen om te zoeken.",
@@ -1184,8 +1184,8 @@
"components.Discover.DiscoverMovies.sortPopularityAsc": "Populariteit oplopend",
"components.Discover.DiscoverMovies.sortPopularityDesc": "Populariteit aflopend",
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "Releasedatum oplopend",
"components.Discover.DiscoverMovies.sortTitleAsc": "Titel oplopend (A-Z)",
"components.Discover.DiscoverMovies.sortTitleDesc": "Titel aflopend (Z-A)",
"components.Discover.DiscoverMovies.sortTitleAsc": "Titel (A-Z) oplopend",
"components.Discover.DiscoverMovies.sortTitleDesc": "Titel (Z-A) aflopend",
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "TMDB-beoordeling oplopend",
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "TMDB-beoordeling aflopend",
"components.Discover.DiscoverSliderEdit.deletefail": "Slider verwijderen mislukt.",
@@ -1202,7 +1202,7 @@
"components.Discover.FilterSlideover.from": "Van",
"components.Discover.FilterSlideover.genres": "Genres",
"components.Discover.FilterSlideover.keywords": "Trefwoorden",
"components.Discover.FilterSlideover.originalLanguage": "Oorspronkelijke taal",
"components.Discover.FilterSlideover.originalLanguage": "Originele taal",
"components.Discover.FilterSlideover.ratingText": "Beoordelingen tussen {minValue} en {maxValue}",
"components.Discover.FilterSlideover.releaseDate": "Releasedatum",
"components.Discover.FilterSlideover.runtime": "Duur",
@@ -1261,88 +1261,5 @@
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Elke {jobScheduleSeconds, plural, one {seconde} other {{jobScheduleSeconds} seconden}}",
"components.Settings.SettingsJobsCache.availability-sync": "Synchronisatie van mediabeschikbaarheid",
"components.Discover.tmdbmoviestreamingservices": "Streamingdiensten voor films TMDB",
"components.Discover.tmdbtvstreamingservices": "Streamingdiensten voor series TMDB",
"components.Login.validationhostrequired": "{mediaServerName}-URL vereist",
"components.Layout.UserWarnings.emailInvalid": "E-mailadres is ongeldig.",
"components.Login.description": "Aangezien dit de eerste keer is dat je je aanmeldt bij {applicationName}, dien je een geldig e-mailadres op te geven.",
"components.Login.saving": "Toevoegen…",
"components.ManageSlideOver.removearr": "Verwijderen van {arr}",
"components.Settings.RadarrModal.tagRequests": "Aanvragen taggen",
"components.MovieDetails.openradarr4k": "Film openen in 4K-Radarr",
"components.Settings.RadarrModal.tagRequestsInfo": "Automatisch een extra label toevoegen met de gebruikers-id en weergavenaam van de aanvrager",
"components.Settings.SonarrModal.animeSeriesType": "Serietype anime",
"components.Settings.SonarrModal.tagRequestsInfo": "Automatisch een extra label toevoegen met de gebruikers-id en weergavenaam van de aanvrager",
"components.Settings.internalUrl": "Interne URL",
"components.Settings.jellyfinsettings": "{mediaServerName}-instellingen",
"components.Settings.jellyfinlibrariesDescription": "De {mediaServerName}-bibliotheken die op titels worden gescand. Klik op onderstaande knop als er geen bibliotheken in de lijst staan.",
"components.Settings.manualscanDescriptionJellyfin": "Normaliter wordt dit eenmaal per 24 uur uitgevoerd. Jellyseerr zal de lijst met onlangs toegevoegde media op je {mediaServerName}-server vaker controleren. Als dit de eerste keer is dat je Jellyseerr instelt, wordt aanbevolen eenmalig een handmatige, volledige bibliotheekscan uit te voeren!",
"components.Settings.save": "Wijzigingen opslaan",
"components.Settings.syncJellyfin": "Bibliotheken synchoniseren",
"components.TvDetails.play": "Afspelen op {mediaServerName}",
"components.Discover.FilterSlideover.tmdbuservotecount": "Aantal gebruikersstemmen TMDB",
"components.Login.save": "Toevoegen",
"components.ManageSlideOver.manageModalRemoveMediaWarning": "* Hiermee wordt deze {mediaType} onomkeerbaar verwijderd van {arr}, inclusief alle bestanden.",
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Apparaatstandaard",
"components.Settings.Notifications.userEmailRequired": "Gebruikerse-mail vereisen",
"components.Settings.SettingsAbout.supportjellyseerr": "Jellyseerr ondersteunen",
"components.Settings.SonarrModal.seriesType": "Serietype",
"components.Settings.jellyfinSettings": "{mediaServerName}-instellingen",
"components.Setup.configuremediaserver": "Mediaserver instellen",
"components.TvDetails.play4k": "Afspelen op {mediaServerName} in 4K",
"components.UserList.mediaServerUser": "{mediaServerName}-gebruiker",
"components.UserList.noJellyfinuserstoimport": "Er zijn geen {mediaServerName}-gebruikers om te importeren.",
"components.UserProfile.UserSettings.UserGeneralSettings.email": "E-mail",
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Apparaatstandaard",
"components.UserProfile.UserSettings.UserNotificationSettings.sound": "Meldingsgeluid",
"components.Login.signinwithjellyfin": "{mediaServerName}-account gebruiken",
"components.Discover.FilterSlideover.voteCount": "Aantal stemmen tussen {minValue} en {maxValue}",
"components.Layout.UserWarnings.emailRequired": "Een e-mailadres is vereist.",
"components.Layout.UserWarnings.passwordRequired": "Een wachtwoord is vereist.",
"components.Login.credentialerror": "Gebruikersnaam of wachtwoord is onjuist.",
"components.Login.emailtooltip": "Het adres hoeft niet gelieerd te zijn aan je {mediaServerName}-instantie.",
"components.Login.host": "{mediaServerName}-URL",
"components.Login.initialsignin": "Verbinden",
"components.Login.initialsigningin": "Verbinden…",
"components.Login.title": "E-mail toevoegen",
"components.Login.username": "Gebruikersnaam",
"components.Login.validationEmailRequired": "Je moet een e-mailadres opgeven",
"components.Login.validationEmailFormat": "Ongeldig e-mailadres",
"components.Login.validationemailformat": "Geldig e-mailadres vereist",
"components.Login.validationhostformat": "Geldige URL vereist",
"components.Login.validationusernamerequired": "Gebruikersnaam vereist",
"components.ManageSlideOver.removearr4k": "Verwijderen van 4K-{arr}",
"components.MovieDetails.downloadstatus": "Downloadstatus",
"components.MovieDetails.imdbuserscore": "Gebruikersbeoordeling IMDB",
"components.MovieDetails.openradarr": "Film openen in Radarr",
"components.MovieDetails.play": "Afspelen op {mediaServerName}",
"components.MovieDetails.play4k": "Afspelen op {mediaServerName} in 4K",
"components.Settings.Notifications.NotificationsPushover.sound": "Meldingsgeluid",
"components.Settings.SonarrModal.tagRequests": "Aanvragen taggen",
"components.Settings.jellyfinSettingsFailure": "Er is iets misgegaan bij het opslaan van de {mediaServerName}-instellingen.",
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName}-instellingen opgeslagen!",
"components.Settings.jellyfinlibraries": "{mediaServerName}-bibliotheken",
"components.Settings.manualscanJellyfin": "Handmatige bibliotheekscan",
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
"components.Settings.saving": "Opslaan…",
"components.Settings.syncing": "Synchroniseren",
"components.Settings.timeout": "Time-out",
"components.Setup.signin": "Aanmelden",
"components.Setup.signinWithJellyfin": "{mediaServerName}-account gebruiken",
"components.TitleCard.addToWatchList": "Toevoegen aan kijklijst",
"components.TitleCard.watchlistError": "Er is iets misgegaan. Probeer het opnieuw.",
"components.UserList.importfromJellyfin": "{mediaServerName}-gebruikers importeren",
"components.UserList.importfromJellyfinerror": "Er is iets misgegaan bij het importeren van {mediaServerName}-gebruikers.",
"components.UserProfile.UserSettings.UserGeneralSettings.mediaServerUser": "{mediaServerName}-gebruiker",
"components.UserProfile.UserSettings.UserGeneralSettings.save": "Wijzigingen opslaan",
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Opslaan…",
"i18n.collection": "Collectie",
"components.UserProfile.localWatchlist": "Kijklijst van {username}",
"components.Setup.signinWithPlex": "Plex-account gebruiken",
"components.Settings.jellyfinSettingsDescription": "Optioneel, configureer de interne en externe eindpunten voor uw {mediaServerName} server. In de meeste gevallen verschilt de externe URL met de interne URL. Een aangepaste wachtwoord reset URL kan ook gebruikt worden voor de {mediaServerName} login, voor het geval dat u doorverwezen wilt worden naar een andere wachtwoord reset pagina.",
"components.Settings.jellyfinsettingsDescription": "Configureer de instellingen voor uw {mediaServerName} server. {mediaServerName} scanned uw {mediaServerName} bibliotheken om te zien welke content beschikbaar is.",
"components.TitleCard.watchlistDeleted": "<strong>{title}</strong> Is succesvol verwijderd van de kijklijst!",
"components.TitleCard.watchlistSuccess": "<strong>{title}</strong> succesvol toegevoegd aan de kijklijst!",
"components.TitleCard.watchlistCancel": "kijklijst voor <strong>{title}</strong> is geannuleerd.",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} succesvol geimporteerd!",
"components.UserList.newJellyfinsigninenabled": "De <strong>Gebruik Nieuwe {mediaServerName} Login</strong> instelling staat momenteel aan. {mediaServerName} gebruikers met toegang tot de bibliotheek, hoeven niet geïmporteerd te worden om in te kunnen loggen."
"components.Discover.tmdbtvstreamingservices": "Streamingdiensten voor series TMDB"
}

View File

@@ -1060,75 +1060,5 @@
"components.Layout.UserDropdown.requests": "Prośby",
"components.MovieDetails.rtaudiencescore": "Ocena Rotten Tomatoes",
"components.MovieDetails.rtcriticsscore": "Tomatometer Rotten Tomatoes",
"components.MovieDetails.tmdbuserscore": "Ocena użytkowników TMDB",
"components.Discover.CreateSlider.addSlider": "Dodaj suwak",
"components.Discover.CreateSlider.addcustomslider": "Utwórz niestandardowy suwak",
"components.Discover.CreateSlider.addfail": "Nie udało się utworzyć nowego suwaka.",
"components.Discover.CreateSlider.addsuccess": "Stworzony nowy suwak i zapisano dostosowywania odkrywania.",
"components.Discover.CreateSlider.editSlider": "Edytuj suwak",
"components.Discover.CreateSlider.editfail": "Nie udało się edytować suwaka.",
"components.Discover.CreateSlider.needresults": "Musisz mieć przynajmniej 1 wynik.",
"components.Discover.CreateSlider.nooptions": "Brak wyników.",
"components.Discover.CreateSlider.providetmdbgenreid": "Podaj ID gatunku TMDB",
"components.Discover.CreateSlider.providetmdbnetwork": "Podaj ID stacji TMDB",
"components.Discover.CreateSlider.providetmdbsearch": "Podaj zapytanie wyszukiwania",
"components.Discover.CreateSlider.providetmdbstudio": "Podaj ID studia TMDB",
"components.Discover.CreateSlider.searchGenres": "Szukaj gatunków…",
"components.Discover.CreateSlider.searchStudios": "Wyszukaj studia…",
"components.Discover.CreateSlider.slidernameplaceholder": "Nazwa suwaka",
"components.Discover.CreateSlider.starttyping": "Zacznij pisać aby wyszukać.",
"components.Discover.CreateSlider.validationDatarequired": "Należy wprowadzić wartość danych.",
"components.Discover.CreateSlider.validationTitlerequired": "Należy podać tytuł.",
"components.Discover.DiscoverMovieKeyword.keywordMovies": "Filmy: {keywordTitle}",
"components.Discover.DiscoverMovies.discovermovies": "Filmy",
"components.Discover.DiscoverMovies.sortPopularityAsc": "Popularność rosnąco",
"components.Discover.DiscoverMovies.sortPopularityDesc": "Popularność malejąco",
"components.Discover.DiscoverMovies.sortReleaseDateDesc": "Data wydania malejąco",
"components.Discover.DiscoverMovies.sortTitleAsc": "Tytuł (A-Z) rosnąco",
"components.Discover.DiscoverMovies.sortTitleDesc": "Tytuł (Z-A) malejąco",
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "Ocena TMDB rosnąco",
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "Ocena TMDB malejąco",
"components.Discover.DiscoverSliderEdit.deletefail": "Nie udało się usunąć suwaka.",
"components.Discover.DiscoverSliderEdit.deletesuccess": "Pomyślnie usunięto suwak.",
"components.Discover.DiscoverSliderEdit.enable": "Przełącz widoczność",
"components.Discover.DiscoverSliderEdit.remove": "Usuń",
"components.Discover.DiscoverTv.sortFirstAirDateAsc": "Data premiery rosnąco",
"components.Discover.DiscoverTv.sortFirstAirDateDesc": "Data premiery malejąco",
"components.Discover.DiscoverTv.sortPopularityAsc": "Popularność rosnąco",
"components.Discover.DiscoverTv.sortPopularityDesc": "Popularność malejąco",
"components.Discover.DiscoverTv.sortTitleAsc": "Tytuł (A-Z) rosnąco",
"components.Discover.DiscoverTv.sortTitleDesc": "Tytuł (Z-A) malejąco",
"components.Discover.DiscoverTv.sortTmdbRatingDesc": "Ocena TMDB malejąco",
"components.Discover.FilterSlideover.clearfilters": "Wyczyść aktywne filtry",
"components.Discover.FilterSlideover.filters": "Filtry",
"components.Discover.FilterSlideover.firstAirDate": "Data pierwszego wyemitowania",
"components.Discover.FilterSlideover.from": "Od",
"components.Discover.FilterSlideover.genres": "Gatunki",
"components.Discover.FilterSlideover.keywords": "Słowa kluczowe",
"components.Discover.FilterSlideover.ratingText": "Oceny między {minValue} a {maxValue}",
"components.Discover.CreateSlider.providetmdbkeywordid": "Podaj ID słowa kluczowego TMDB",
"components.Discover.CreateSlider.searchKeywords": "Wyszukaj słowa kluczowe…",
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "Data wydania rosnąco",
"components.Discover.DiscoverTv.discovertv": "Seriale",
"components.Discover.DiscoverTv.sortTmdbRatingAsc": "Ocena TMDB rosnąco",
"components.Discover.DiscoverTvKeyword.keywordSeries": "Seriale: {keywordTitle}",
"components.Discover.FilterSlideover.originalLanguage": "Oryginalny język",
"components.Discover.FilterSlideover.releaseDate": "Data wydania",
"components.Discover.FilterSlideover.runtime": "Długość",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minut trwania",
"components.Discover.FilterSlideover.streamingservices": "Serwisy streamingowe",
"components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuservotecount": "Liczba głosów użytkowników TMDB",
"components.Discover.FilterSlideover.to": "Do",
"components.Discover.FilterSlideover.voteCount": "Liczba głosów między {minValue} a {maxValue}",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "Twoja lista obserwowanych Plex",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Niedawno dodane",
"components.Discover.createnewslider": "Dodaj nowy suwak",
"components.Discover.customizediscover": "Dostosuj Odkryj",
"components.Discover.PlexWatchlistSlider.emptywatchlist": "Media dodane do twojej <PlexWatchlistSupportLink>listy obserwowanych Plex</PlexWatchlistSupportLink> zostaną wyświetlone tutaj.",
"components.Discover.emptywatchlist": "Media dodane do twojej <PlexWatchlistSupportLink>listy obserwowanych Plex</PlexWatchlistSupportLink> zostaną wyświetlone tutaj.",
"components.Discover.tmdbstudio": "Studio TMDB",
"components.Discover.tmdbtvkeyword": "Słowo kluczowe serialu TMDB",
"components.Discover.tmdbtvgenre": "Gatunek serialu TMDB",
"components.Discover.tmdbsearch": "Wyszukiwanie TMDB"
"components.MovieDetails.tmdbuserscore": "Ocena użytkowników TMDB"
}

View File

@@ -1256,36 +1256,5 @@
"components.Discover.tmdbmoviestreamingservices": "Serviços de Streaming de Filmes do TMDB",
"components.Discover.FilterSlideover.tmdbuservotecount": "Qtd de Votos de Usuários TMDB",
"components.Discover.FilterSlideover.voteCount": "Qtd the votos entre {minValue} e {maxValue}",
"components.Settings.RadarrModal.tagRequestsInfo": "Adicione automaticamente uma tag extra com o ID de usuário e o nome de exibição do solicitante",
"components.Layout.UserWarnings.emailRequired": "Um endereço de e-mail é necessário.",
"components.Login.credentialerror": "O nome de usuário ou senha está incorreto.",
"components.Login.description": "Já que é sua primeira vez entrando em {applicationName}, você precisa adicionar um e-mail válido.",
"components.Login.host": "URL de {mediaServerName}",
"components.Login.initialsignin": "Conectar",
"components.Login.initialsigningin": "Conectando…",
"components.Login.save": "Adicionar",
"components.Login.saving": "Adicionando…",
"components.Login.signinwithjellyfin": "Use sua conta de {mediaServerName}",
"components.Login.title": "Adicionar E-Mail",
"components.Login.username": "Nome de usuário",
"components.Login.validationEmailFormat": "E-mail inválido",
"components.Login.validationEmailRequired": "Você precisa providenciar um e-mail",
"components.Login.validationemailformat": "E-mail válido necessário",
"components.Login.validationhostformat": "URL válido necessário",
"components.Login.validationusernamerequired": "Nome de usuário necessário",
"components.ManageSlideOver.removearr": "Remover de {arr}",
"components.ManageSlideOver.removearr4k": "Remover de {arr} 4K",
"components.MovieDetails.downloadstatus": "Status de download",
"components.MovieDetails.imdbuserscore": "Avaliação de usuário no IMDB",
"components.MovieDetails.openradarr": "Abrir filme no Radarr",
"components.Settings.Notifications.NotificationsPushover.sound": "Som de notificação",
"components.Login.emailtooltip": "Endereço não precisa ser associado à sua instância de {mediaServerName}.",
"components.Layout.UserWarnings.emailInvalid": "Endereço de e-mail inválido.",
"components.Layout.UserWarnings.passwordRequired": "Uma senha é necessária.",
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Padrão do dispositivo",
"components.Login.validationhostrequired": "URL de {mediaServerName} necessário",
"components.MovieDetails.openradarr4k": "Abrir filme em Radarr 4K",
"components.MovieDetails.play": "Reproduzir em {mediaServerName}",
"components.ManageSlideOver.manageModalRemoveMediaWarning": "* Isto irá remover este {mediaType} de {arr}, incluindo todos os arquivos.",
"components.MovieDetails.play4k": "Reproduzir em 4K em {mediaServerName}"
"components.Settings.RadarrModal.tagRequestsInfo": "Adicione automaticamente uma tag extra com o ID de usuário e o nome de exibição do solicitante"
}

View File

@@ -406,37 +406,5 @@
"components.RequestButton.approve4krequests": "Aprobă {requestCount, plural, o {4K Request} alte {{requestCount} 4K Requests}}",
"components.RequestBlock.lastmodifiedby": "Ultima Dată Modificat de",
"components.RequestBlock.profilechanged": "Profil Calitate",
"components.RequestBlock.requestdate": "Dată Solicitare",
"components.Layout.UserWarnings.emailRequired": "Este necesara o adresa de email.",
"components.Layout.UserWarnings.passwordRequired": "Este necesara o parola.",
"components.Login.credentialerror": "Numele de utilizator sau parola sunt incorecte.",
"components.Login.emailtooltip": "Nu este necesar ca adresa ta sa fie asociata cu instanta ta {mediaServerName} .",
"components.Login.save": "Adauga",
"components.Login.signinwithjellyfin": "Foloseste-ti contul de {mediaServerName}",
"components.Login.title": "Adauga email",
"components.Login.username": "Nume utilizator",
"components.Login.validationEmailFormat": "Adresa email invalida",
"components.Login.validationemailformat": "Necesar email valid",
"components.Login.validationhostformat": "Necesar URL valid",
"components.Login.validationhostrequired": "{mediaServerName} URL necesar",
"components.Login.validationusernamerequired": "Nume utilizator necesar",
"components.MovieDetails.downloadstatus": "Status descarcare",
"components.MovieDetails.openradarr": "Deschide film in Radarr",
"components.MovieDetails.openradarr4k": "Deschide film in 4K Radarr",
"components.MovieDetails.play": "Ruleaza pe {mediaServerName}",
"components.MovieDetails.play4k": "Ruleaza 4k pe {mediaServerName}",
"components.RequestButton.declinerequest": "Refuza cerere",
"components.RequestButton.declinerequest4k": "Refuza cerere 4k",
"components.Layout.UserWarnings.emailInvalid": "Adresa de email este invalida.",
"components.Login.description": "Deoarece este prima ta authentificare in {applicationName}, este necesara sa introduci o adresa de email valida.",
"components.Login.initialsigningin": "Se conecteaza…",
"components.Login.saving": "Se adauga…",
"components.RequestButton.approverequest": "Aproba cerere",
"components.RequestButton.approverequest4k": "Aproba cerere 4k",
"components.Login.host": "{mediaServerName} URL",
"components.Login.initialsignin": "Conecteaza-te",
"components.ManageSlideOver.removearr4k": "Elimina din 4K {arr}",
"components.ManageSlideOver.manageModalRemoveMediaWarning": "* Acesta va elimina ireversibil {mediaType} din {arr}, incluzand toate fisierele asociate.",
"components.Login.validationEmailRequired": "Trebuie sa introduci o adresa email",
"components.ManageSlideOver.removearr": "Elimina din {arr}"
"components.RequestBlock.requestdate": "Dată Solicitare"
}

Some files were not shown because too many files have changed in this diff Show More