From 90a0a2a9ec2bae2be6a2ad27f340c630c21a4206 Mon Sep 17 00:00:00 2001 From: bonswouar Date: Sat, 8 Jun 2024 19:31:05 +0200 Subject: [PATCH 01/10] feat: set movie status to unknown if unmonitored from radarr during scan #695 --- server/lib/scanners/baseScanner.ts | 16 ++++++++++++++++ server/lib/scanners/radarr/index.ts | 8 +------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f0f3db7e6..5bede1cea 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -618,6 +618,22 @@ class BaseScanner { get protectedBundleSize(): number { return this.bundleSize; } + + protected async processUnmonitoredMovie(tmdbId: number): Promise { + const mediaRepository = getRepository(Media); + await this.asyncLock.dispatch(tmdbId, async () => { + const existing = await this.getExisting(tmdbId, MediaType.MOVIE); + // For some reason the status of missing movies isn't PENDING but PROCESSING + if (existing && existing.status === MediaStatus.PROCESSING) { + existing.status = MediaStatus.UNKNOWN; + await mediaRepository.save(existing); + this.log( + `Movie TMDB ID ${tmdbId} unmonitored from Radarr. Media status set to UNKNOWN.`, + 'info' + ); + } + }); + } } export default BaseScanner; diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index bc299d7b1..8d529ebef 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -80,13 +80,7 @@ class RadarrScanner private async processRadarrMovie(radarrMovie: RadarrMovie): Promise { if (!radarrMovie.monitored && !radarrMovie.hasFile) { - this.log( - 'Title is unmonitored and has not been downloaded. Skipping item.', - 'debug', - { - title: radarrMovie.title, - } - ); + this.processUnmonitoredMovie(radarrMovie.tmdbId); return; } From 246e48bfcc5c7d8fbcc83c38bb487abdb6eb8fa7 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 11 Jun 2024 13:23:35 +0200 Subject: [PATCH 02/10] 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 --- .../UserProfile/UserSettings/UserGeneralSettings/index.tsx | 5 +++++ src/i18n/locale/en.json | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 960746adf..b6371e7d4 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -53,6 +53,8 @@ const messages = defineMessages({ discordId: 'Discord User ID', discordIdTip: 'The multi-digit ID number 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)), diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 367fabc37..0b7a8cbff 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1177,6 +1177,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 multi-digit ID number associated with your user account", From 956d24cfe9665d8d78323a7dde193fa2f37313bb Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 11 Jun 2024 14:58:48 +0200 Subject: [PATCH 03/10] 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 --- server/lib/scanners/jellyfin/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index f5b0f66a2..8007e6ef3 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -83,13 +83,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; }); }); From ac82f188b17549be378f40a8f6f579979e1d55b6 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 11 Jun 2024 16:47:02 +0200 Subject: [PATCH 04/10] 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 --- src/components/MovieDetails/index.tsx | 59 +++++++++++++++------------ 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index b7dc59172..4ed69b6b6 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -434,33 +434,38 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )} - {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && ( - - - - )} + {hasPermission(Permission.MANAGE_REQUESTS) && + data.mediaInfo && + (data.mediaInfo.jellyfinMediaId || + data.mediaInfo.jellyfinMediaId4k || + data.mediaInfo.status !== MediaStatus.UNKNOWN || + data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && ( + + + + )}
From b9d7321c560df40b5ef1b172c8c5938045166d3c Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 11 Jun 2024 23:56:10 +0200 Subject: [PATCH 05/10] fix(api): add DNS caching (#810) fix #387 #657 #728 --- package.json | 1 + server/index.ts | 11 +++++++++++ yarn.lock | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/package.json b/package.json index 97c025503..32b66e19d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.ts b/server/index.ts index 477864c26..b62080778 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,6 +23,7 @@ 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'; @@ -32,10 +33,14 @@ 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 +51,12 @@ const handle = app.getRequestHandler(); app .prepare() .then(async () => { + const CacheableLookup = (await _importDynamic('cacheable-lookup')) + .default as typeof CacheableLookupType; + const cacheable = new CacheableLookup(); + cacheable.install(http.globalAgent); + cacheable.install(https.globalAgent); + const dbConnection = await dataSource.initialize(); // Run migrations in production diff --git a/yarn.lock b/yarn.lock index 09b5a3ca0..b94855109 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5033,6 +5033,11 @@ cacache@^16.0.0, cacache@^16.1.0, cacache@^16.1.3: tar "^6.1.11" unique-filename "^2.0.0" +cacheable-lookup@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz#3476a8215d046e5a3202a9209dd13fec1f933a27" + integrity sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w== + cachedir@2.3.0, cachedir@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" From 6628315b4c0057b06b2ec2ef609d3eb66ad18fbc Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Jun 2024 10:49:15 +0500 Subject: [PATCH 06/10] ci: temporarily disable snap release builds (#811) --- .github/workflows/release.yml | 108 +++++++++++++++++----------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1eeacd77d..8f838a52c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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@v4 - with: - fetch-depth: 0 - - name: Switch to main branch - run: git checkout main - - name: Pull latest changes - run: git pull - - name: Prepare - id: prepare - run: | - git fetch --prune --tags - if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then - echo "RELEASE=stable" >> $GITHUB_OUTPUT - else - echo "RELEASE=edge" >> $GITHUB_OUTPUT - fi - - name: Set Up QEMU - uses: docker/setup-qemu-action@v3 - with: - image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde - - name: Build Snap Package - uses: diddlesnaps/snapcraft-multiarch-action@v1 - id: build - with: - architecture: ${{ matrix.architecture }} - - name: Upload Snap Package - uses: actions/upload-artifact@v4 - with: - name: jellyseerr-snap-package-${{ matrix.architecture }} - path: ${{ steps.build.outputs.snap }} - - name: Review Snap Package - uses: diddlesnaps/snapcraft-review-tools-action@v1 - with: - snap: ${{ steps.build.outputs.snap }} - - name: Publish Snap Package - uses: snapcore/action-publish@v1 - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }} - with: - snap: ${{ steps.build.outputs.snap }} - release: ${{ steps.prepare.outputs.RELEASE }} + # build-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 From 6f1e379ec419ba965e1a210f5fd87228bda0fe1e Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:50:00 +0500 Subject: [PATCH 07/10] 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 --- server/routes/auth.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 82c34b153..52c63ff29 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -14,6 +14,7 @@ import { ApiError } from '@server/types/error'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; +import net from 'net'; const authRoutes = Router(); @@ -271,11 +272,21 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ? jellyfinHost.slice(0, -1) : jellyfinHost; - const ip = req.ip ? req.ip.split(':').reverse()[0] : undefined; + 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, - ip + clientIp ); // Next let's see if the user already exists From 32bef51bc6a3999ca09ef4f4d8daad91fecebe1c Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 13 Jun 2024 04:53:12 +0500 Subject: [PATCH 08/10] 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 --- server/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/index.ts b/server/index.ts index b62080778..a9a746562 100644 --- a/server/index.ts +++ b/server/index.ts @@ -27,6 +27,7 @@ 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'; @@ -54,6 +55,19 @@ app 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)); + } else { + originalLookup(...(args as Parameters)); + } + }; + cacheable.install(http.globalAgent); cacheable.install(https.globalAgent); From e175741e9a1d5793c8526f365e255d4dbfc07ee6 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:16:07 +0500 Subject: [PATCH 09/10] 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. --- server/api/jellyfin.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index f23e9aceb..81b505f11 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -126,25 +126,31 @@ class JellyfinAPI extends ExternalAPI { Password?: string, ClientIP?: string ): Promise { - try { - const headers = ClientIP - ? { - 'X-Forwarded-For': ClientIP, - } - : {}; + const authenticate = async (useHeaders: boolean) => { + const headers = + useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {}; - const authResponse = await this.post( + return this.post( '/Users/AuthenticateByName', { - Username: Username, + Username, Pw: Password, }, - { - headers: headers, - } + { headers } ); + }; - return authResponse; + try { + return await authenticate(true); + } catch (e) { + 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; From 548783c6640459777c889509dc5144afb5422e0b Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 13 Jun 2024 22:06:33 +0500 Subject: [PATCH 10/10] refactor(jellyfin): abstract jellyfin hostname, updated ui to reflect it, better validation (#773) * 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. * refactor: remove console logs and use getHostname and ApiErrorCodes * 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. * refactor: clean up commented out code * refactor(i18n): extract translation keys * fix(auth): auth failing with jellyfin login is disabled * fix(settings): jellyfin migrations replacing the rest of the settings * fix(settings): jellyfin hostname should be carried out if hostname exists * fix(settings): merging the wrong settings source * refactor(settings): use migrator for dynamic settings migrations * refactor(settingsmigrator): settings migration handler and the migrations * 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. * chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier * 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. * test(cypress): add only missing jobs to the cypress settings * ci: attempt at trying to get formatter to pass on cypress config json file * refactor: revert the changes brought to try and fix formatter added back the rest of the cypress settings and removed cypress settings from .prettierignore * refactor(settings): better erorr logging when jellyfin connection test fails in settings page --- cypress/config/settings.cypress.json | 27 +++ server/api/jellyfin.ts | 10 + server/constants/error.ts | 2 + server/entity/Media.ts | 12 +- server/lib/availabilitySync.ts | 3 +- server/lib/scanners/jellyfin/index.ts | 5 +- server/lib/{settings.ts => settings/index.ts} | 26 ++- .../migrations/0001_migrate_hostname.ts | 30 +++ server/lib/settings/migrator.ts | 21 ++ server/routes/auth.ts | 43 ++-- server/routes/settings/index.ts | 77 +++++-- server/routes/user/index.ts | 11 +- server/utils/getHostname.ts | 18 ++ src/components/Login/JellyfinLogin.tsx | 140 +++++++++--- src/components/Settings/SettingsJellyfin.tsx | 208 ++++++++++++++---- src/i18n/locale/en.json | 14 +- 16 files changed, 529 insertions(+), 118 deletions(-) rename server/lib/{settings.ts => settings/index.ts} (97%) create mode 100644 server/lib/settings/migrations/0001_migrate_hostname.ts create mode 100644 server/lib/settings/migrator.ts create mode 100644 server/utils/getHostname.ts diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 7a4bbef5d..45e38a29e 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -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 * * *" } } } diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 81b505f11..6c72ad577 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -184,6 +184,16 @@ class JellyfinAPI extends ExternalAPI { return; } + public async getSystemInfo(): Promise { + try { + const systemInfoResponse = await this.get('/System/Info'); + + return systemInfoResponse; + } catch (e) { + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + } + } + public async getServerName(): Promise { try { const serverResponse = await this.get( diff --git a/server/constants/error.ts b/server/constants/error.ts index 22b9ad60a..ac18c3ec8 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -3,5 +3,7 @@ export enum ApiErrorCode { InvalidCredentials = 'INVALID_CREDENTIALS', InvalidAuthToken = 'INVALID_AUTH_TOKEN', NotAdmin = 'NOT_ADMIN', + SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', + SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', Unknown = 'UNKNOWN', } diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 1932670e4..102185be1 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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}`; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 8b37bc85e..1aa37cf9a 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -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 ); diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index 8007e6ef3..fa7cdb225 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -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'; @@ -594,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 ); diff --git a/server/lib/settings.ts b/server/lib/settings/index.ts similarity index 97% rename from server/lib/settings.ts rename to server/lib/settings/index.ts index 63f952363..ad613cc30 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings/index.ts @@ -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; diff --git a/server/lib/settings/migrations/0001_migrate_hostname.ts b/server/lib/settings/migrations/0001_migrate_hostname.ts new file mode 100644 index 000000000..c514ac2db --- /dev/null +++ b/server/lib/settings/migrations/0001_migrate_hostname.ts @@ -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; diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts new file mode 100644 index 000000000..9d709590d --- /dev/null +++ b/server/lib/settings/migrator.ts @@ -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; +}; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 52c63ff29..3b0d7e382 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -11,6 +11,7 @@ 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'; @@ -222,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 @@ -261,17 +271,14 @@ 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; @@ -328,8 +335,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => { const serverName = await jellyfinserver.getServerName(); settings.jellyfin.name = serverName; - settings.jellyfin.hostname = body.hostname ?? ''; 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(); @@ -444,7 +454,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => { label: 'Auth', error: e.errorCode, status: e.statusCode, - hostname: body.hostname, + hostname: getHostname({ + useSsl: body.useSsl, + ip: body.hostname, + port: body.port, + urlBase: body.urlBase, + }), } ); return next({ diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 41821dcac..64fd83a61 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -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,11 +255,59 @@ 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); }); @@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', + getHostname(), admin.jellyfinAuthToken ?? '', admin.jellyfinDeviceId ?? '' ); @@ -288,10 +339,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { // Automatic Library grouping is not supported when user views are used to get library if (account.Configuration.GroupedFolders.length > 0) { - return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' }); + return next({ + status: 501, + message: ApiErrorCode.SyncErrorGroupedFolders, + }); } - return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' }); + return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries }); } const newLibraries: Library[] = libraries.map((library) => { @@ -322,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { }); 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'], @@ -339,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', admin.jellyfinAuthToken ?? '', admin.jellyfinDeviceId ?? '' ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 789c90765..6b0953e68 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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(); diff --git a/server/utils/getHostname.ts b/server/utils/getHostname.ts new file mode 100644 index 000000000..9fa110cd1 --- /dev/null +++ b/server/utils/getHostname.ts @@ -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; +}; diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 7403392e9..d3945be54 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -14,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.', @@ -24,6 +27,11 @@ 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.', @@ -51,16 +59,23 @@ const JellyfinLogin: React.FC = ({ 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)) @@ -75,12 +90,16 @@ const JellyfinLogin: React.FC = ({ mediaServerName: publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', }; + return ( = ({ 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) { @@ -121,32 +143,100 @@ const JellyfinLogin: React.FC = ({ } }} > - {({ errors, touched, isSubmitting, isValid }) => ( + {({ + errors, + touched, + values, + setFieldValue, + isSubmitting, + isValid, + }) => (
-