From 8de2544db81b35e75894e2e93faae83643416d51 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 20 Feb 2026 22:40:25 +0100 Subject: [PATCH 01/29] feat(settings): add endpoint and UI for switching media server --- seerr-api.yml | 28 + server/routes/auth.ts | 44 +- server/routes/settings/index.ts | 152 ++- server/routes/user/usersettings.ts | 73 +- server/subscriber/MediaSubscriber.ts | 4 +- src/components/Settings/SettingsLayout.tsx | 45 +- .../Settings/SettingsMain/index.tsx | 4 + src/components/Settings/SettingsPlex.tsx | 1126 +++++++++-------- .../Settings/SwitchMediaServerSection.tsx | 125 ++ .../UserLinkedAccountsSettings/index.tsx | 19 +- src/i18n/locale/en.json | 2 + 11 files changed, 1034 insertions(+), 588 deletions(-) create mode 100644 src/components/Settings/SwitchMediaServerSection.tsx diff --git a/seerr-api.yml b/seerr-api.yml index 99ef16cc3c..299db62afc 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -2290,6 +2290,34 @@ paths: application/json: schema: $ref: '#/components/schemas/JellyfinSettings' + /settings/switch-media-server: + post: + summary: Switch media server + tags: + - settings + responses: + '200': + description: Media server cleared + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Media server cleared. Restart or reload to configure a new server.' + '400': + description: No media server is configured + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'No media server is configured.' + '500': + description: Failed to switch media server /settings/jellyfin/library: get: summary: Get Jellyfin libraries diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d625d85ec7..3a6fb7507f 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -58,6 +58,39 @@ authRoutes.post('/plex', async (req, res, next) => { }); } + const mediaServerType = settings.main.mediaServerType; + + // When main server is not Plex, allow storing the token for Plex settings + if (mediaServerType !== MediaServerType.PLEX) { + if (!req.user) { + return next({ + status: 401, + message: 'Authentication required.', + }); + } + try { + const plextv = new PlexTvAPI(body.authToken); + const account = await plextv.getUser(); + const admin = await userRepository.findOneOrFail({ + where: { id: 1 }, + }); + admin.plexToken = body.authToken; + admin.plexId = account.id; + admin.plexUsername = account.username; + await userRepository.save(admin); + return res.status(200).json({ email: admin.email }); + } catch (e) { + logger.error('Failed to store Plex token for settings', { + label: 'API', + errorMessage: (e as Error).message, + }); + return next({ + status: 500, + message: 'Unable to validate Plex token.', + }); + } + } + if ( settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && (settings.main.mediaServerLogin === false || @@ -410,9 +443,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.apiKey = apiKey; await settings.save(); startJobs(); - } - // User already exists, let's update their information - else if (account.User.Id === user?.jellyfinUserId) { + } else if (account.User.Id === user?.jellyfinUserId) { logger.info( `Found matching ${ settings.main.mediaServerType === MediaServerType.JELLYFIN @@ -431,6 +462,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ); user.avatar = getUserAvatarUrl(user); user.jellyfinUsername = account.User.Name; + user.userType = + settings.main.mediaServerType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY; + user.plexId = null; + user.plexUsername = null; + user.plexToken = null; if (user.username === account.User.Name) { user.username = ''; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b5746595..037f497c02 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -3,17 +3,20 @@ 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 { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; +import { Watchlist } from '@server/entity/Watchlist'; import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; import type { LogMessage, LogsResultsResponse, SettingsAboutResponse, } from '@server/interfaces/api/settingsInterfaces'; -import { scheduledJobs } from '@server/job/schedule'; +import { scheduledJobs, startJobs } from '@server/job/schedule'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; import ImageProxy from '@server/lib/imageproxy'; @@ -118,15 +121,30 @@ settingsRoutes.get('/plex', (_req, res) => { settingsRoutes.post('/plex', async (req, res, next) => { const userRepository = getRepository(User); const settings = getSettings(); + const body = req.body as Record; + const { authToken: bodyToken, ...plexBody } = body; try { const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); - Object.assign(settings.plex, req.body); + Object.assign(settings.plex, plexBody); - const plexClient = new PlexAPI({ plexToken: admin.plexToken }); + const token = + settings.main.mediaServerType !== MediaServerType.PLEX && + typeof bodyToken === 'string' + ? bodyToken + : admin.plexToken; + if (!token) { + return next({ + status: 400, + message: + 'Sign in with Plex to verify the connection when the main server is not Plex.', + }); + } + + const plexClient = new PlexAPI({ plexToken: token }); const result = await plexClient.getStatus(); @@ -154,18 +172,17 @@ settingsRoutes.post('/plex', async (req, res, next) => { settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { const userRepository = getRepository(User); + const settings = getSettings(); try { const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); - const plexTvClient = admin.plexToken - ? new PlexTvAPI(admin.plexToken) - : null; + const authToken = admin.plexToken ?? null; + const plexTvClient = authToken ? new PlexTvAPI(authToken) : null; const devices = (await plexTvClient?.getDevices())?.filter((device) => { return device.provides.includes('server') && device.owned; }); - const settings = getSettings(); if (devices) { await Promise.all( @@ -198,7 +215,7 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { useSsl: connection.protocol === 'https', }; const plexClient = new PlexAPI({ - plexToken: admin.plexToken, + plexToken: authToken, plexSettings: plexDeviceSettings, timeout: 5000, }); @@ -432,6 +449,125 @@ settingsRoutes.post('/jellyfin/sync', (req, res) => { } return res.status(200).json(jellyfinFullScanner.status()); }); + +settingsRoutes.post('/switch-media-server', async (req, res, next) => { + const settings = getSettings(); + const current = settings.main.mediaServerType; + const body = (req.body as { targetServerType?: string }) ?? {}; + if (current === MediaServerType.NOT_CONFIGURED) { + return res.status(400).json({ + error: 'No media server is configured.', + }); + } + try { + if (current === MediaServerType.PLEX) { + const useEmby = body.targetServerType === 'emby'; + settings.main.mediaServerType = useEmby + ? MediaServerType.EMBY + : MediaServerType.JELLYFIN; + settings.plex = { + name: '', + ip: '', + port: 32400, + useSsl: false, + libraries: [], + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ plexId: null, plexUsername: null, plexToken: null }) + .where('user.id >= :zero', { zero: 0 }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + userType: useEmby ? UserType.EMBY : UserType.JELLYFIN, + }) + .where('user.jellyfinUserId IS NOT NULL') + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ ratingKey: null, ratingKey4k: null }) + .where('media.ratingKey IS NOT NULL OR media.ratingKey4k IS NOT NULL') + .execute(); + const watchlistRepository = getRepository(Watchlist); + await watchlistRepository + .createQueryBuilder() + .update(Watchlist) + .set({ ratingKey: '' }) + .where("watchlist.ratingKey != ''") + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: useEmby + ? 'Switched to Emby. Restart or reload if the app does not update.' + : 'Switched to Jellyfin. Restart or reload if the app does not update.', + }); + } else if ( + current === MediaServerType.JELLYFIN || + current === MediaServerType.EMBY + ) { + settings.main.mediaServerType = MediaServerType.PLEX; + settings.jellyfin = { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .where('user.id >= :zero', { zero: 0 }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ userType: UserType.PLEX }) + .where('user.plexId IS NOT NULL') + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) + .where( + 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' + ) + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: + 'Switched to Plex. Restart or reload if the app does not update.', + }); + } + } catch (e) { + logger.error('Switch media server failed', { + label: 'Settings', + errorMessage: e.message, + }); + return next({ status: 500, message: 'Failed to switch media server.' }); + } +}); + settingsRoutes.get('/tautulli', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index bd5af746f5..578314f223 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -275,24 +275,36 @@ userSettingsRoutes.post<{ authToken: string }>( if (!req.user) { return res.status(404).json({ code: ApiErrorCode.Unauthorized }); } - // Make sure Plex login is enabled - if (settings.main.mediaServerType !== MediaServerType.PLEX) { - return res.status(500).json({ message: 'Plex login is disabled' }); + const isMainPlex = settings.main.mediaServerType === MediaServerType.PLEX; + const plexForLinking = isMainPlex || settings.plex?.name ? true : false; + if (!plexForLinking) { + return res.status(400).json({ + message: + 'Plex is not the main server and Plex connection is not configured. Configure Plex in Settings for account linking.', + }); } // First we need to use this auth token to get the user's email from plex.tv const plextv = new PlexTvAPI(req.body.authToken); const account = await plextv.getUser(); - // Do not allow linking of an already linked account - if (await userRepository.exist({ where: { plexId: account.id } })) { + const user = req.user; + const existingUser = await userRepository.findOne({ + where: { plexId: account.id }, + }); + if (existingUser) { + if (existingUser.id === user.id) { + user.plexId = account.id; + user.plexUsername = account.username; + user.plexToken = account.authToken; + await userRepository.save(user); + return res.status(204).send(); + } return res.status(422).json({ message: 'This Plex account is already linked to a Seerr user', }); } - const user = req.user; - // Emails do not match if (user.email !== account.email) { return res.status(422).json({ @@ -302,7 +314,9 @@ userSettingsRoutes.post<{ authToken: string }>( } // valid plex user found, link to current user - user.userType = UserType.PLEX; + if (isMainPlex) { + user.userType = UserType.PLEX; + } user.plexId = account.id; user.plexUsername = account.username; user.plexToken = account.authToken; @@ -319,9 +333,12 @@ userSettingsRoutes.delete<{ id: string }>( const settings = getSettings(); const userRepository = getRepository(User); - // Make sure Plex login is enabled - if (settings.main.mediaServerType !== MediaServerType.PLEX) { - return res.status(500).json({ message: 'Plex login is disabled' }); + const isMainPlex = settings.main.mediaServerType === MediaServerType.PLEX; + const plexConfigured = isMainPlex || settings.plex?.name; + if (!plexConfigured) { + return res.status(400).json({ + message: 'Plex is not configured. Configure Plex in Settings first.', + }); } try { @@ -373,14 +390,17 @@ userSettingsRoutes.post<{ username: string; password: string }>( if (!req.user) { return res.status(401).json({ code: ApiErrorCode.Unauthorized }); } - // Make sure jellyfin login is enabled - if ( - settings.main.mediaServerType !== MediaServerType.JELLYFIN && - settings.main.mediaServerType !== MediaServerType.EMBY - ) { - return res - .status(500) - .json({ message: 'Jellyfin/Emby login is disabled' }); + + const isMainJellyfin = + settings.main.mediaServerType === MediaServerType.JELLYFIN || + settings.main.mediaServerType === MediaServerType.EMBY; + const jellyfinForLinking = settings.jellyfin; + + if (!isMainJellyfin && !jellyfinForLinking?.ip) { + return res.status(400).json({ + message: + 'Jellyfin is not the main server and Jellyfin connection is not configured. Ask an admin to set Jellyfin in Settings (connection only) for account linking.', + }); } // Do not allow linking of an already linked account @@ -394,7 +414,9 @@ userSettingsRoutes.post<{ username: string; password: string }>( }); } - const hostname = getHostname(); + const hostname = isMainJellyfin + ? getHostname() + : getHostname(jellyfinForLinking); const deviceId = Buffer.from( req.user?.id === 1 ? 'BOT_seerr' : `BOT_seerr_${req.user.username ?? ''}` ).toString('base64'); @@ -431,11 +453,12 @@ userSettingsRoutes.post<{ username: string; password: string }>( const user = req.user; - // valid jellyfin user found, link to current user - user.userType = - settings.main.mediaServerType === MediaServerType.EMBY - ? UserType.EMBY - : UserType.JELLYFIN; + if (isMainJellyfin) { + user.userType = + settings.main.mediaServerType === MediaServerType.EMBY + ? UserType.EMBY + : UserType.JELLYFIN; + } user.jellyfinUserId = account.User.Id; user.jellyfinUsername = account.User.Name; user.jellyfinAuthToken = account.AccessToken; diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 3cf8229f04..9c290a4484 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -122,7 +122,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { } public async beforeUpdate(event: UpdateEvent): Promise { - if (!event.entity) { + if (!event.entity || !event.databaseEntity) { return; } @@ -153,7 +153,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { } public async afterUpdate(event: UpdateEvent): Promise { - if (!event.entity) { + if (!event.entity || !event.databaseEntity) { return; } diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index f01a5685a5..f5321fce5a 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -28,6 +28,39 @@ type SettingsLayoutProps = { const SettingsLayout = ({ children }: SettingsLayoutProps) => { const intl = useIntl(); const settings = useSettings(); + const mediaServerType = settings.currentSettings.mediaServerType; + const mediaServerTabs: SettingsRoute[] = + mediaServerType === MediaServerType.PLEX + ? [ + { + text: intl.formatMessage(messages.menuPlexSettings), + route: '/settings/plex', + regex: /^\/settings\/plex/, + }, + { + text: intl.formatMessage(messages.menuJellyfinSettings, { + mediaServerName: 'Jellyfin', + }), + route: '/settings/jellyfin', + regex: /^\/settings\/jellyfin/, + }, + ] + : mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ? [ + { + text: getAvailableMediaServerName(), + route: '/settings/jellyfin', + regex: /^\/settings\/jellyfin/, + }, + { + text: intl.formatMessage(messages.menuPlexSettings), + route: '/settings/plex', + regex: /^\/settings\/plex/, + }, + ] + : []; + const settingsRoutes: SettingsRoute[] = [ { text: intl.formatMessage(messages.menuGeneralSettings), @@ -39,17 +72,7 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => { route: '/settings/users', regex: /^\/settings\/users/, }, - settings.currentSettings.mediaServerType === MediaServerType.PLEX - ? { - text: intl.formatMessage(messages.menuPlexSettings), - route: '/settings/plex', - regex: /^\/settings\/plex/, - } - : { - text: getAvailableMediaServerName(), - route: '/settings/jellyfin', - regex: /^\/settings\/jellyfin/, - }, + ...mediaServerTabs, { text: intl.formatMessage(messages.menuServices), route: '/settings/services', diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 4944c2349f..5b9b9c1ee1 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -7,6 +7,7 @@ import LanguageSelector from '@app/components/LanguageSelector'; import RegionSelector from '@app/components/RegionSelector'; import CopyButton from '@app/components/Settings/CopyButton'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; +import SwitchMediaServerSection from '@app/components/Settings/SwitchMediaServerSection'; import { availableLanguages } from '@app/context/LanguageContext'; import useLocale from '@app/hooks/useLocale'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -581,6 +582,9 @@ const SettingsMain = () => { }} + {currentUser && userHasPermission(Permission.ADMIN) && ( + + )} ); }; diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 0cb1c11987..d9d0dad507 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -4,8 +4,10 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import SensitiveInput from '@app/components/Common/SensitiveInput'; +import PlexLoginButton from '@app/components/Login/PlexLoginButton'; import LibraryItem from '@app/components/Settings/LibraryItem'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; +import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { isValidURL } from '@app/utils/urlValidationHelper'; @@ -15,6 +17,7 @@ import { MagnifyingGlassIcon, XMarkIcon, } from '@heroicons/react/24/solid'; +import { MediaServerType } from '@server/constants/server'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import type { PlexSettings, TautulliSettings } from '@server/lib/settings'; import axios from 'axios'; @@ -81,6 +84,9 @@ const messages = defineMessages('components.Settings', { toastTautulliSettingsSuccess: 'Tautulli settings saved successfully!', toastTautulliSettingsFailure: 'Something went wrong while saving Tautulli settings.', + plexConnectionForLinking: + 'Configure the Plex connection so users can link their Plex account in Profile => Linked accounts before you switch media server.', + signInWithPlex: 'Sign in with Plex', }); interface Library { @@ -132,6 +138,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { ); const intl = useIntl(); const { addToast, removeToast } = useToasts(); + const settings = useSettings(); const PlexSettingsSchema = Yup.object().shape({ hostname: Yup.string() @@ -332,11 +339,17 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { revalidate(); }; - if ((!data || !dataTautulli) && !error) { + const isMainPlex = + settings.currentSettings.mediaServerType === MediaServerType.PLEX; + if (isMainPlex && (!data || !dataTautulli) && !error) { return ; } + if (!isMainPlex && !data && !error) { + return ; + } + return ( - <> +
{

{intl.formatMessage(messages.plexsettings)}

- {intl.formatMessage(messages.plexsettingsDescription)} + {isMainPlex + ? intl.formatMessage(messages.plexsettingsDescription) + : intl.formatMessage(messages.plexConnectionForLinking)}

- {!!onComplete && ( + {!isMainPlex && ( +
+ { + try { + await axios.post('/api/v1/auth/plex', { authToken }); + addToast( + intl.formatMessage(messages.toastPlexConnectingSuccess), + { + appearance: 'success', + } + ); + revalidate(); + } catch (e) { + addToast( + axios.isAxiosError(e) && e.response?.data?.message + ? String(e.response.data.message) + : intl.formatMessage(messages.toastPlexConnectingFailure), + { appearance: 'error' } + ); + } + }} + /> +
+ )} + {!!onComplete && isMainPlex && (
{
)}
- { - let toastId: string | null = null; - try { - addToast( - intl.formatMessage(messages.toastPlexConnecting), - { - autoDismiss: false, - appearance: 'info', - }, - (id) => { - toastId = id; - } - ); - await axios.post('/api/v1/settings/plex', { - ip: values.hostname, - port: Number(values.port), - useSsl: values.useSsl, - webAppUrl: values.webAppUrl, - } as PlexSettings); +
+ { + let toastId: string | null = null; + try { + addToast( + intl.formatMessage(messages.toastPlexConnecting), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/plex', { + ip: values.hostname, + port: Number(values.port), + useSsl: values.useSsl, + webAppUrl: values.webAppUrl, + } as PlexSettings); - syncLibraries(); + syncLibraries(); - if (toastId) { - removeToast(toastId); - } - addToast(intl.formatMessage(messages.toastPlexConnectingSuccess), { - autoDismiss: true, - appearance: 'success', - }); - } catch (e) { - if (toastId) { - removeToast(toastId); + if (toastId) { + removeToast(toastId); + } + addToast( + intl.formatMessage(messages.toastPlexConnectingSuccess), + { + autoDismiss: true, + appearance: 'success', + } + ); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast( + intl.formatMessage(messages.toastPlexConnectingFailure), + { + autoDismiss: true, + appearance: 'error', + } + ); } - addToast(intl.formatMessage(messages.toastPlexConnectingFailure), { - autoDismiss: true, - appearance: 'error', - }); - } - }} - > - {({ - errors, - touched, - values, - handleSubmit, - setFieldValue, - setValues, - isSubmitting, - isValid, - }) => { - return ( -
-
- -
-
- { + const targPreset = + availablePresets[Number(e.target.value)]; - if (targPreset) { - setValues({ - ...values, - hostname: targPreset.address, - port: targPreset.port, - useSsl: targPreset.ssl, - }); - } - }} - > - - {availablePresets.map((server, index) => ( - + {availablePresets.map((server, index) => ( + - ))} - - +
+
+
+
+ +
+
+ + {values.useSsl ? 'https://' : 'http://'} + + - +
+ {errors.hostname && + touched.hostname && + typeof errors.hostname === 'string' && ( +
{errors.hostname}
+ )}
-
-
- -
-
- - {values.useSsl ? 'https://' : 'http://'} - +
+ +
+ {errors.port && + touched.port && + typeof errors.port === 'string' && ( +
{errors.port}
+ )}
- {errors.hostname && - touched.hostname && - typeof errors.hostname === 'string' && ( -
{errors.hostname}
- )} -
-
-
- -
- - {errors.port && - touched.port && - typeof errors.port === 'string' && ( -
{errors.port}
- )} -
-
-
- -
- { - setFieldValue('useSsl', !values.useSsl); - }} - />
-
-
- -
-
+
+ +
{ + setFieldValue('useSsl', !values.useSsl); + }} />
- {errors.webAppUrl && - touched.webAppUrl && - typeof errors.webAppUrl === 'string' && ( -
{errors.webAppUrl}
- )}
-
-
-
- - - +
+ +
+
+ +
+ {errors.webAppUrl && + touched.webAppUrl && + typeof errors.webAppUrl === 'string' && ( +
{errors.webAppUrl}
+ )} +
-
- - ); - }} - -
-

- {intl.formatMessage(messages.plexlibraries)} -

-

- {intl.formatMessage(messages.plexlibrariesDescription)} -

-
-
- -
    - {data?.libraries.map((library) => ( - toggleLibrary(library.id)} +
    +
    + + + +
    +
    + + ); + }} + +
    +

    + {intl.formatMessage(messages.plexlibraries)} +

    +

    + {intl.formatMessage(messages.plexlibrariesDescription)} +

    +
    +
    +
-
-
-

{intl.formatMessage(messages.manualscan)}

-

- {intl.formatMessage(messages.manualscanDescription)} -

-
-
-
-
- {dataSync?.running && ( -
+ {isSyncing + ? intl.formatMessage(messages.scanning) + : intl.formatMessage(messages.scan)} + + +
    + {data?.libraries.map((library) => ( + toggleLibrary(library.id)} /> - )} -
    - - {dataSync?.running - ? `${dataSync.progress} of ${dataSync.total}` - : 'Not running'} - + ))} +
+
+
+

{intl.formatMessage(messages.manualscan)}

+

+ {intl.formatMessage(messages.manualscanDescription)} +

+
+
+
+
+ {dataSync?.running && ( +
+ )} +
+ + {dataSync?.running + ? `${dataSync.progress} of ${dataSync.total}` + : 'Not running'} + +
-
-
- {dataSync?.running && ( - <> - {dataSync.currentLibrary && ( -
- - {intl.formatMessage(messages.currentlibrary, { - name: dataSync.currentLibrary.name, +
+ {dataSync?.running && ( + <> + {dataSync.currentLibrary && ( +
+ + {intl.formatMessage(messages.currentlibrary, { + name: dataSync.currentLibrary.name, + })} + +
+ )} +
+ + {intl.formatMessage(messages.librariesRemaining, { + count: dataSync.currentLibrary + ? dataSync.libraries.slice( + dataSync.libraries.findIndex( + (library) => + library.id === dataSync.currentLibrary?.id + ) + 1 + ).length + : 0, })}
- )} -
- - {intl.formatMessage(messages.librariesRemaining, { - count: dataSync.currentLibrary - ? dataSync.libraries.slice( - dataSync.libraries.findIndex( - (library) => - library.id === dataSync.currentLibrary?.id - ) + 1 - ).length - : 0, - })} - -
- - )} -
- {!dataSync?.running ? ( - - ) : ( - + )} +
+ {!dataSync?.running ? ( + + ) : ( + + )} +
-
- {!onComplete && ( - <> -
-

- {intl.formatMessage(messages.tautulliSettings)} -

-

- {intl.formatMessage(messages.tautulliSettingsDescription)} -

-
- { - try { - await axios.post('/api/v1/settings/tautulli', { - hostname: values.tautulliHostname, - port: Number(values.tautulliPort), - useSsl: values.tautulliUseSsl, - urlBase: values.tautulliUrlBase, - apiKey: values.tautulliApiKey, - externalUrl: values.tautulliExternalUrl, - } as TautulliSettings); + {!onComplete && ( + <> +
+

+ {intl.formatMessage(messages.tautulliSettings)} +

+

+ {intl.formatMessage(messages.tautulliSettingsDescription)} +

+
+ { + try { + await axios.post('/api/v1/settings/tautulli', { + hostname: values.tautulliHostname, + port: Number(values.tautulliPort), + useSsl: values.tautulliUseSsl, + urlBase: values.tautulliUrlBase, + apiKey: values.tautulliApiKey, + externalUrl: values.tautulliExternalUrl, + } as TautulliSettings); - addToast( - intl.formatMessage(messages.toastTautulliSettingsSuccess), - { - autoDismiss: true, - appearance: 'success', - } - ); - } catch (e) { - addToast( - intl.formatMessage(messages.toastTautulliSettingsFailure), - { - autoDismiss: true, - appearance: 'error', - } - ); - } finally { - revalidateTautulli(); - } - }} - > - {({ - errors, - touched, - values, - handleSubmit, - setFieldValue, - isSubmitting, - isValid, - }) => { - return ( -
-
- -
-
- - {values.tautulliUseSsl ? 'https://' : 'http://'} - - + addToast( + intl.formatMessage(messages.toastTautulliSettingsSuccess), + { + autoDismiss: true, + appearance: 'success', + } + ); + } catch (e) { + addToast( + intl.formatMessage(messages.toastTautulliSettingsFailure), + { + autoDismiss: true, + appearance: 'error', + } + ); + } finally { + revalidateTautulli(); + } + }} + > + {({ + errors, + touched, + values, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }) => { + return ( + +
+ +
+
+ + {values.tautulliUseSsl ? 'https://' : 'http://'} + + +
+ {errors.tautulliHostname && + touched.tautulliHostname && + typeof errors.tautulliHostname === 'string' && ( +
+ {errors.tautulliHostname} +
+ )}
- {errors.tautulliHostname && - touched.tautulliHostname && - typeof errors.tautulliHostname === 'string' && ( -
{errors.tautulliHostname}
- )} -
-
-
- -
- - {errors.tautulliPort && - touched.tautulliPort && - typeof errors.tautulliPort === 'string' && ( -
{errors.tautulliPort}
- )} -
-
-
- -
- { - setFieldValue( - 'tautulliUseSsl', - !values.tautulliUseSsl - ); - }} - />
-
-
- -
-
+
+ +
+ {errors.tautulliPort && + touched.tautulliPort && + typeof errors.tautulliPort === 'string' && ( +
{errors.tautulliPort}
+ )}
- {errors.tautulliUrlBase && - touched.tautulliUrlBase && - typeof errors.tautulliUrlBase === 'string' && ( -
{errors.tautulliUrlBase}
- )}
-
-
- -
-
- + +
+ { + setFieldValue( + 'tautulliUseSsl', + !values.tautulliUseSsl + ); + }} />
- {errors.tautulliApiKey && - touched.tautulliApiKey && - typeof errors.tautulliApiKey === 'string' && ( -
{errors.tautulliApiKey}
- )}
-
-
- -
-
- +
+ +
+
+ +
+ {errors.tautulliUrlBase && + touched.tautulliUrlBase && + typeof errors.tautulliUrlBase === 'string' && ( +
+ {errors.tautulliUrlBase} +
+ )}
- {errors.tautulliExternalUrl && - touched.tautulliExternalUrl && ( -
- {errors.tautulliExternalUrl} -
- )}
-
-
-
- - - +
+ +
+
+ +
+ {errors.tautulliApiKey && + touched.tautulliApiKey && + typeof errors.tautulliApiKey === 'string' && ( +
{errors.tautulliApiKey}
+ )} +
-
- - ); - }} - - - )} - +
+ +
+
+ +
+ {errors.tautulliExternalUrl && + touched.tautulliExternalUrl && ( +
+ {errors.tautulliExternalUrl} +
+ )} +
+
+
+
+ + + +
+
+ + ); + }} + + + )} +
+
); }; diff --git a/src/components/Settings/SwitchMediaServerSection.tsx b/src/components/Settings/SwitchMediaServerSection.tsx new file mode 100644 index 0000000000..c72e9c5faf --- /dev/null +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -0,0 +1,125 @@ +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import useSettings from '@app/hooks/useSettings'; +import globalMessages from '@app/i18n/globalMessages'; +import { MediaServerType } from '@server/constants/server'; +import axios from 'axios'; +import { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; + +type SwitchTargetServerType = 'jellyfin' | 'emby'; + +const SwitchMediaServerSection = () => { + const settings = useSettings(); + const intl = useIntl(); + const { addToast } = useToasts(); + const [switchTargetServerType, setSwitchTargetServerType] = + useState('jellyfin'); + + if ( + settings.currentSettings.mediaServerType === MediaServerType.NOT_CONFIGURED + ) { + return null; + } + + return ( +
+

+ +

+

+ {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( + Profile + ), + linkedAccounts: ( + + Linked accounts + + ), + }} + /> + ) : ( + Profile + ), + linkedAccounts: ( + + Linked accounts + + ), + }} + /> + )} +

+ {settings.currentSettings.mediaServerType === MediaServerType.PLEX && ( +
+ + +
+ )} + { + try { + await axios.post( + '/api/v1/settings/switch-media-server', + settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? { targetServerType: switchTargetServerType } + : undefined + ); + addToast('Media server cleared. You may need to restart.', { + appearance: 'success', + }); + window.location.reload(); + } catch (err: unknown) { + const message = + axios.isAxiosError(err) && err.response?.data?.error + ? String(err.response.data.error) + : intl.formatMessage(globalMessages.blocklistError); + addToast(message, { appearance: 'error' }); + } + }} + > + + +
+ ); +}; + +export default SwitchMediaServerSection; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index 9fcbe3cc3a..4ac5b1d090 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -70,17 +70,20 @@ const UserLinkedAccountsSettings = () => { const accounts: LinkedAccount[] = useMemo(() => { const accounts: LinkedAccount[] = []; if (!user) return accounts; - if (user.userType === UserType.PLEX && user.plexUsername) + if (user.plexUsername || user.userType === UserType.PLEX) accounts.push({ type: LinkedAccountType.Plex, - username: user.plexUsername, + username: user.plexUsername ?? 'Plex', }); if (user.userType === UserType.EMBY && user.jellyfinUsername) accounts.push({ type: LinkedAccountType.Emby, username: user.jellyfinUsername, }); - if (user.userType === UserType.JELLYFIN && user.jellyfinUsername) + if ( + user.jellyfinUsername && + (user.userType === UserType.JELLYFIN || user.userType === UserType.PLEX) + ) accounts.push({ type: LinkedAccountType.Jellyfin, username: user.jellyfinUsername, @@ -121,14 +124,20 @@ const UserLinkedAccountsSettings = () => { setTimeout(() => linkPlexAccount(), 1500); }, hide: - settings.currentSettings.mediaServerType !== MediaServerType.PLEX || + (settings.currentSettings.mediaServerType !== MediaServerType.PLEX && + settings.currentSettings.mediaServerType !== + MediaServerType.JELLYFIN && + settings.currentSettings.mediaServerType !== MediaServerType.EMBY) || accounts.some((a) => a.type === LinkedAccountType.Plex), }, { name: 'Jellyfin', action: () => setShowJellyfinModal(true), hide: - settings.currentSettings.mediaServerType !== MediaServerType.JELLYFIN || + (settings.currentSettings.mediaServerType !== + MediaServerType.JELLYFIN && + settings.currentSettings.mediaServerType !== MediaServerType.EMBY && + settings.currentSettings.mediaServerType !== MediaServerType.PLEX) || accounts.some((a) => a.type === LinkedAccountType.Jellyfin), }, { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 06ad3fecbd..57c4843136 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1204,6 +1204,7 @@ "components.Settings.overrideRules": "Override Rules", "components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.", "components.Settings.plex": "Plex", + "components.Settings.plexConnectionForLinking": "Configure the Plex connection so users can link their Plex account in Profile => Linked accounts before you switch media server.", "components.Settings.plexlibraries": "Plex Libraries", "components.Settings.plexlibrariesDescription": "The libraries Seerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.", "components.Settings.plexsettings": "Plex Settings", @@ -1230,6 +1231,7 @@ "components.Settings.services": "Services", "components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.", "components.Settings.settings": "Settings", + "components.Settings.signInWithPlex": "Sign in with Plex", "components.Settings.sonarrsettings": "Sonarr Settings", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", From a08e6bfb0dce560628d378c7fd376a77e62012fc Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 20 Feb 2026 23:33:06 +0100 Subject: [PATCH 02/29] feat(auth, settings): enforce admin permissions --- server/routes/auth.ts | 6 + server/routes/settings/index.ts | 230 ++++++++++++++++---------------- 2 files changed, 123 insertions(+), 113 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 3a6fb7507f..e1868b0675 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -68,6 +68,12 @@ authRoutes.post('/plex', async (req, res, next) => { message: 'Authentication required.', }); } + if (!req.user.hasPermission(Permission.ADMIN)) { + return next({ + status: 403, + message: 'Admin permissions required.', + }); + } try { const plextv = new PlexTvAPI(body.authToken); const account = await plextv.getUser(); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 037f497c02..ffe38cf580 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -450,123 +450,127 @@ settingsRoutes.post('/jellyfin/sync', (req, res) => { return res.status(200).json(jellyfinFullScanner.status()); }); -settingsRoutes.post('/switch-media-server', async (req, res, next) => { - const settings = getSettings(); - const current = settings.main.mediaServerType; - const body = (req.body as { targetServerType?: string }) ?? {}; - if (current === MediaServerType.NOT_CONFIGURED) { - return res.status(400).json({ - error: 'No media server is configured.', - }); - } - try { - if (current === MediaServerType.PLEX) { - const useEmby = body.targetServerType === 'emby'; - settings.main.mediaServerType = useEmby - ? MediaServerType.EMBY - : MediaServerType.JELLYFIN; - settings.plex = { - name: '', - ip: '', - port: 32400, - useSsl: false, - libraries: [], - }; - const userRepository = getRepository(User); - await userRepository - .createQueryBuilder() - .update(User) - .set({ plexId: null, plexUsername: null, plexToken: null }) - .where('user.id >= :zero', { zero: 0 }) - .execute(); - await userRepository - .createQueryBuilder() - .update(User) - .set({ - userType: useEmby ? UserType.EMBY : UserType.JELLYFIN, - }) - .where('user.jellyfinUserId IS NOT NULL') - .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository - .createQueryBuilder() - .update(Media) - .set({ ratingKey: null, ratingKey4k: null }) - .where('media.ratingKey IS NOT NULL OR media.ratingKey4k IS NOT NULL') - .execute(); - const watchlistRepository = getRepository(Watchlist); - await watchlistRepository - .createQueryBuilder() - .update(Watchlist) - .set({ ratingKey: '' }) - .where("watchlist.ratingKey != ''") - .execute(); - await settings.save(); - startJobs(); - return res.status(200).json({ - message: useEmby - ? 'Switched to Emby. Restart or reload if the app does not update.' - : 'Switched to Jellyfin. Restart or reload if the app does not update.', +settingsRoutes.post( + '/switch-media-server', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const settings = getSettings(); + const current = settings.main.mediaServerType; + const body = (req.body as { targetServerType?: string }) ?? {}; + if (current === MediaServerType.NOT_CONFIGURED) { + return res.status(400).json({ + error: 'No media server is configured.', }); - } else if ( - current === MediaServerType.JELLYFIN || - current === MediaServerType.EMBY - ) { - settings.main.mediaServerType = MediaServerType.PLEX; - settings.jellyfin = { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }; - const userRepository = getRepository(User); - await userRepository - .createQueryBuilder() - .update(User) - .set({ - jellyfinUserId: null, - jellyfinUsername: null, - jellyfinAuthToken: null, - jellyfinDeviceId: null, - }) - .where('user.id >= :zero', { zero: 0 }) - .execute(); - await userRepository - .createQueryBuilder() - .update(User) - .set({ userType: UserType.PLEX }) - .where('user.plexId IS NOT NULL') - .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository - .createQueryBuilder() - .update(Media) - .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) - .where( - 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' - ) - .execute(); - await settings.save(); - startJobs(); - return res.status(200).json({ - message: - 'Switched to Plex. Restart or reload if the app does not update.', + } + try { + if (current === MediaServerType.PLEX) { + const useEmby = body.targetServerType === 'emby'; + settings.main.mediaServerType = useEmby + ? MediaServerType.EMBY + : MediaServerType.JELLYFIN; + settings.plex = { + name: '', + ip: '', + port: 32400, + useSsl: false, + libraries: [], + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ plexId: null, plexUsername: null, plexToken: null }) + .where('user.id >= :zero', { zero: 0 }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + userType: useEmby ? UserType.EMBY : UserType.JELLYFIN, + }) + .where('user.jellyfinUserId IS NOT NULL') + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ ratingKey: null, ratingKey4k: null }) + .where('media.ratingKey IS NOT NULL OR media.ratingKey4k IS NOT NULL') + .execute(); + const watchlistRepository = getRepository(Watchlist); + await watchlistRepository + .createQueryBuilder() + .update(Watchlist) + .set({ ratingKey: '' }) + .where("watchlist.ratingKey != ''") + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: useEmby + ? 'Switched to Emby. Restart or reload if the app does not update.' + : 'Switched to Jellyfin. Restart or reload if the app does not update.', + }); + } else if ( + current === MediaServerType.JELLYFIN || + current === MediaServerType.EMBY + ) { + settings.main.mediaServerType = MediaServerType.PLEX; + settings.jellyfin = { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .where('user.id >= :zero', { zero: 0 }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ userType: UserType.PLEX }) + .where('user.plexId IS NOT NULL') + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) + .where( + 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' + ) + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: + 'Switched to Plex. Restart or reload if the app does not update.', + }); + } + } catch (e) { + logger.error('Switch media server failed', { + label: 'Settings', + errorMessage: e.message, }); + return next({ status: 500, message: 'Failed to switch media server.' }); } - } catch (e) { - logger.error('Switch media server failed', { - label: 'Settings', - errorMessage: e.message, - }); - return next({ status: 500, message: 'Failed to switch media server.' }); } -}); +); settingsRoutes.get('/tautulli', (_req, res) => { const settings = getSettings(); From 60e982d92254321573f69e605455d6838427fd4d Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 20 Feb 2026 23:34:52 +0100 Subject: [PATCH 03/29] fix(settings): enable reinitialization for Tautulli settings form --- src/components/Settings/SettingsPlex.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index d9d0dad507..b8c8dc2703 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -798,6 +798,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { tautulliApiKey: dataTautulli?.apiKey, tautulliExternalUrl: dataTautulli?.externalUrl, }} + enableReinitialize validationSchema={TautulliSettingsSchema} onSubmit={async (values) => { try { From 99fd071856e73aec7eb9a4cc04f6b15b8746d082 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 20 Feb 2026 23:39:09 +0100 Subject: [PATCH 04/29] fix(settings): use proper error messages --- .../Settings/SwitchMediaServerSection.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/Settings/SwitchMediaServerSection.tsx b/src/components/Settings/SwitchMediaServerSection.tsx index c72e9c5faf..ca37d757d6 100644 --- a/src/components/Settings/SwitchMediaServerSection.tsx +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -1,6 +1,7 @@ import ConfirmButton from '@app/components/Common/ConfirmButton'; import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; import { useState } from 'react'; @@ -9,6 +10,12 @@ import { useToasts } from 'react-toast-notifications'; type SwitchTargetServerType = 'jellyfin' | 'emby'; +const messages = defineMessages('components.Settings', { + switchMediaServerError: + 'Something went wrong while switching media server. Please try again.', + switchMediaServerSuccess: 'Media server cleared. You may need to restart.', +}); + const SwitchMediaServerSection = () => { const settings = useSettings(); const intl = useIntl(); @@ -100,15 +107,22 @@ const SwitchMediaServerSection = () => { ? { targetServerType: switchTargetServerType } : undefined ); - addToast('Media server cleared. You may need to restart.', { + addToast(intl.formatMessage(messages.switchMediaServerSuccess), { appearance: 'success', }); window.location.reload(); } catch (err: unknown) { + const extracted = axios.isAxiosError(err) + ? (err.response?.data?.error ?? + err.response?.data?.message ?? + err.message) + : err instanceof Error + ? err.message + : null; const message = - axios.isAxiosError(err) && err.response?.data?.error - ? String(err.response.data.error) - : intl.formatMessage(globalMessages.blocklistError); + extracted != null && String(extracted).trim() !== '' + ? String(extracted) + : intl.formatMessage(messages.switchMediaServerError); addToast(message, { appearance: 'error' }); } }} From 3d9365befb860725e0224bed32dd6fd0a94194ba Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 20 Feb 2026 23:41:38 +0100 Subject: [PATCH 05/29] fix(settings): allow jellyfin/emby unlinking before migration --- server/routes/user/usersettings.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 578314f223..f55edb92d5 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -491,14 +491,15 @@ userSettingsRoutes.delete<{ id: string }>( const settings = getSettings(); const userRepository = getRepository(User); - // Make sure jellyfin login is enabled - if ( - settings.main.mediaServerType !== MediaServerType.JELLYFIN && - settings.main.mediaServerType !== MediaServerType.EMBY - ) { - return res - .status(500) - .json({ message: 'Jellyfin/Emby login is disabled' }); + const isMainJellyfin = + settings.main.mediaServerType === MediaServerType.JELLYFIN || + settings.main.mediaServerType === MediaServerType.EMBY; + const jellyfinForLinking = settings.jellyfin; + if (!isMainJellyfin && !jellyfinForLinking?.ip) { + return res.status(400).json({ + message: + 'Jellyfin is not the main server and Jellyfin connection is not configured. Configure Jellyfin in Settings to allow unlinking.', + }); } try { From 7de8a56505edccdd94742c1e2b2840e525bda231 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 20 Feb 2026 23:44:29 +0100 Subject: [PATCH 06/29] fix: missing translations --- src/i18n/locale/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 57c4843136..9d12b49529 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1236,6 +1236,8 @@ "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", "components.Settings.starttyping": "Starting typing to search.", + "components.Settings.switchMediaServerError": "Something went wrong while switching media server. Please try again.", + "components.Settings.switchMediaServerSuccess": "Media server cleared. You may need to restart.", "components.Settings.syncJellyfin": "Sync Libraries", "components.Settings.syncing": "Syncing", "components.Settings.tautulliApiKey": "API Key", From 600d350540449882583433d93a8a3d2c8d31112d Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 18:54:16 +0100 Subject: [PATCH 07/29] fix(settings): remove unnecessary user ID condition for plex and jellyfin unlinking --- server/routes/settings/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index ffe38cf580..c6bb96a38e 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -480,7 +480,6 @@ settingsRoutes.post( .createQueryBuilder() .update(User) .set({ plexId: null, plexUsername: null, plexToken: null }) - .where('user.id >= :zero', { zero: 0 }) .execute(); await userRepository .createQueryBuilder() @@ -538,7 +537,6 @@ settingsRoutes.post( jellyfinAuthToken: null, jellyfinDeviceId: null, }) - .where('user.id >= :zero', { zero: 0 }) .execute(); await userRepository .createQueryBuilder() From 62581dc6ec1ac354e7ed6ff537d3acf2c60cffcc Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 18:55:53 +0100 Subject: [PATCH 08/29] fix(MediaSubscriber): remove optional chaining for status checks --- server/subscriber/MediaSubscriber.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 9c290a4484..81994d7e69 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -175,7 +175,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { }; if ( - (event.entity.status !== event.databaseEntity?.status || + (event.entity.status !== event.databaseEntity.status || (event.entity.mediaType === MediaType.TV && seasonStatusCheck(false))) && validStatuses.includes(event.entity.status) @@ -188,7 +188,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { } if ( - (event.entity.status4k !== event.databaseEntity?.status4k || + (event.entity.status4k !== event.databaseEntity.status4k || (event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) && validStatuses.includes(event.entity.status4k) ) { From 473ccb26eab538778efea3063cb040ca09053628 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 19:20:38 +0100 Subject: [PATCH 09/29] feat(settings): add switching logic to support Jellyfin and Emby transitions --- server/routes/settings/index.ts | 198 +++++++++++++++++++++++++------- 1 file changed, 154 insertions(+), 44 deletions(-) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index c6bb96a38e..0c9e350053 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -514,50 +514,160 @@ settingsRoutes.post( current === MediaServerType.JELLYFIN || current === MediaServerType.EMBY ) { - settings.main.mediaServerType = MediaServerType.PLEX; - settings.jellyfin = { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }; - const userRepository = getRepository(User); - await userRepository - .createQueryBuilder() - .update(User) - .set({ - jellyfinUserId: null, - jellyfinUsername: null, - jellyfinAuthToken: null, - jellyfinDeviceId: null, - }) - .execute(); - await userRepository - .createQueryBuilder() - .update(User) - .set({ userType: UserType.PLEX }) - .where('user.plexId IS NOT NULL') - .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository - .createQueryBuilder() - .update(Media) - .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) - .where( - 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' - ) - .execute(); - await settings.save(); - startJobs(); - return res.status(200).json({ - message: - 'Switched to Plex. Restart or reload if the app does not update.', + const targetJellyfinType = body.targetServerType; + const switchToJellyfin = targetJellyfinType === 'jellyfin'; + const switchToEmby = targetJellyfinType === 'emby'; + const switchToPlex = targetJellyfinType === 'plex'; + + if (switchToJellyfin && current !== MediaServerType.JELLYFIN) { + // Emby → Jellyfin + settings.main.mediaServerType = MediaServerType.JELLYFIN; + settings.jellyfin = { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ userType: UserType.JELLYFIN }) + .where('user.jellyfinUserId IS NOT NULL') + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) + .where( + 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' + ) + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: + 'Switched to Jellyfin. Reconfigure the server and have users sign in again. Restart or reload if the app does not update.', + }); + } + + if (switchToEmby && current !== MediaServerType.EMBY) { + // Jellyfin → Emby + settings.main.mediaServerType = MediaServerType.EMBY; + settings.jellyfin = { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ userType: UserType.EMBY }) + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) + .where( + 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' + ) + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: + 'Switched to Emby. Reconfigure the server and have users sign in again. Restart or reload if the app does not update.', + }); + } + + if (switchToPlex) { + // Jellyfin/Emby → Plex + settings.main.mediaServerType = MediaServerType.PLEX; + settings.jellyfin = { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }; + const userRepository = getRepository(User); + await userRepository + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .execute(); + await userRepository + .createQueryBuilder() + .update(User) + .set({ userType: UserType.PLEX }) + .where('user.plexId IS NOT NULL') + .execute(); + const mediaRepository = getRepository(Media); + await mediaRepository + .createQueryBuilder() + .update(Media) + .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) + .where( + 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' + ) + .execute(); + await settings.save(); + startJobs(); + return res.status(200).json({ + message: + 'Switched to Plex. Restart or reload if the app does not update.', + }); + } + + return res.status(400).json({ + error: + 'Specify targetServerType: "plex", "jellyfin", or "emby" to switch media server.', }); } } catch (e) { From 9302aacdb023649a89bbf40fb30a126535ab9dab Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 19:22:45 +0100 Subject: [PATCH 10/29] fix(settings): remove condition for user plexId in update query --- server/routes/settings/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 0c9e350053..592858addd 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -646,7 +646,6 @@ settingsRoutes.post( .createQueryBuilder() .update(User) .set({ userType: UserType.PLEX }) - .where('user.plexId IS NOT NULL') .execute(); const mediaRepository = getRepository(Media); await mediaRepository From 1ec5aef342209503e5b71e3af6e741eb0d00b6bf Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 19:29:41 +0100 Subject: [PATCH 11/29] fix(userSettings): improve account linking logic to prevent conflicts with existing users --- server/routes/user/usersettings.ts | 39 +++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index f55edb92d5..6628b26225 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -403,12 +403,11 @@ userSettingsRoutes.post<{ username: string; password: string }>( }); } - // Do not allow linking of an already linked account - if ( - await userRepository.exist({ - where: { jellyfinUsername: req.body.username }, - }) - ) { + // Do not allow linking of an already linked account (by another user) + const existingByUsername = await userRepository.findOne({ + where: { jellyfinUsername: req.body.username }, + }); + if (existingByUsername && existingByUsername.id !== req.user!.id) { return res.status(422).json({ message: 'The specified account is already linked to a Seerr user', }); @@ -440,19 +439,31 @@ userSettingsRoutes.post<{ username: string; password: string }>( clientIp ); - // Do not allow linking of an already linked account - if ( - await userRepository.exist({ - where: { jellyfinUserId: account.User.Id }, - }) - ) { + const user = req.user; + const existingByUserId = await userRepository.findOne({ + where: { jellyfinUserId: account.User.Id }, + }); + + if (existingByUserId) { + if (existingByUserId.id === user.id) { + if (isMainJellyfin) { + user.userType = + settings.main.mediaServerType === MediaServerType.EMBY + ? UserType.EMBY + : UserType.JELLYFIN; + } + user.jellyfinUserId = account.User.Id; + user.jellyfinUsername = account.User.Name; + user.jellyfinAuthToken = account.AccessToken; + user.jellyfinDeviceId = deviceId; + await userRepository.save(user); + return res.status(204).send(); + } return res.status(422).json({ message: 'The specified account is already linked to a Seerr user', }); } - const user = req.user; - if (isMainJellyfin) { user.userType = settings.main.mediaServerType === MediaServerType.EMBY From 4a829f732260066f6f4e642c80364b55bd4d36be Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 19:41:39 +0100 Subject: [PATCH 12/29] fix(auth): update token storage logic for Plex when using Jellyfin or Emby as main server --- server/routes/auth.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index e1868b0675..5aa76a25d6 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -60,8 +60,11 @@ authRoutes.post('/plex', async (req, res, next) => { const mediaServerType = settings.main.mediaServerType; - // When main server is not Plex, allow storing the token for Plex settings - if (mediaServerType !== MediaServerType.PLEX) { + // When main server is Jellyfin/Emby, allow admin to store Plex token for settings (linking) + if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { if (!req.user) { return next({ status: 401, From f44c722671fb4b49d9584f25703ea42e17cbb0e6 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 20:13:03 +0100 Subject: [PATCH 13/29] feat(settings): log out all users after successful switch --- server/routes/settings/index.ts | 31 ++++++++++++++++--- .../Settings/SwitchMediaServerSection.tsx | 19 +++++++----- src/i18n/locale/en.json | 2 +- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 592858addd..2515fe7c3c 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -8,6 +8,7 @@ import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; +import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; @@ -504,11 +505,16 @@ settingsRoutes.post( .where("watchlist.ratingKey != ''") .execute(); await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); startJobs(); return res.status(200).json({ message: useEmby - ? 'Switched to Emby. Restart or reload if the app does not update.' - : 'Switched to Jellyfin. Restart or reload if the app does not update.', + ? 'Switched to Emby. All users have been logged out. Restart the server, then sign in with the new media server.' + : 'Switched to Jellyfin. All users have been logged out. Restart the server, then sign in with the new media server.', }); } else if ( current === MediaServerType.JELLYFIN || @@ -561,10 +567,15 @@ settingsRoutes.post( ) .execute(); await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); startJobs(); return res.status(200).json({ message: - 'Switched to Jellyfin. Reconfigure the server and have users sign in again. Restart or reload if the app does not update.', + 'Switched to Jellyfin. All users have been logged out. Restart the server, then reconfigure and sign in with the new media server.', }); } @@ -609,10 +620,15 @@ settingsRoutes.post( ) .execute(); await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); startJobs(); return res.status(200).json({ message: - 'Switched to Emby. Reconfigure the server and have users sign in again. Restart or reload if the app does not update.', + 'Switched to Emby. All users have been logged out. Restart the server, then reconfigure and sign in with the new media server.', }); } @@ -657,10 +673,15 @@ settingsRoutes.post( ) .execute(); await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); startJobs(); return res.status(200).json({ message: - 'Switched to Plex. Restart or reload if the app does not update.', + 'Switched to Plex. All users have been logged out. Restart the server, then sign in with the new media server.', }); } diff --git a/src/components/Settings/SwitchMediaServerSection.tsx b/src/components/Settings/SwitchMediaServerSection.tsx index ca37d757d6..6fb8ee2716 100644 --- a/src/components/Settings/SwitchMediaServerSection.tsx +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -13,7 +13,8 @@ type SwitchTargetServerType = 'jellyfin' | 'emby'; const messages = defineMessages('components.Settings', { switchMediaServerError: 'Something went wrong while switching media server. Please try again.', - switchMediaServerSuccess: 'Media server cleared. You may need to restart.', + switchMediaServerSuccess: + 'Media server switched. All users logged out. Restart the server, then sign in again.', }); const SwitchMediaServerSection = () => { @@ -41,7 +42,7 @@ const SwitchMediaServerSection = () => { {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( Profile @@ -56,7 +57,7 @@ const SwitchMediaServerSection = () => { ) : ( Profile @@ -101,15 +102,19 @@ const SwitchMediaServerSection = () => { confirmText={intl.formatMessage(globalMessages.areyousure)} onClick={async () => { try { - await axios.post( + const { data } = await axios.post<{ message?: string }>( '/api/v1/settings/switch-media-server', settings.currentSettings.mediaServerType === MediaServerType.PLEX ? { targetServerType: switchTargetServerType } : undefined ); - addToast(intl.formatMessage(messages.switchMediaServerSuccess), { - appearance: 'success', - }); + addToast( + data?.message ?? + intl.formatMessage(messages.switchMediaServerSuccess), + { + appearance: 'success', + } + ); window.location.reload(); } catch (err: unknown) { const extracted = axios.isAxiosError(err) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 9d12b49529..35a2c51f3c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1237,7 +1237,7 @@ "components.Settings.startscan": "Start Scan", "components.Settings.starttyping": "Starting typing to search.", "components.Settings.switchMediaServerError": "Something went wrong while switching media server. Please try again.", - "components.Settings.switchMediaServerSuccess": "Media server cleared. You may need to restart.", + "components.Settings.switchMediaServerSuccess": "Media server switched. All users logged out. Restart the server, then sign in again.", "components.Settings.syncJellyfin": "Sync Libraries", "components.Settings.syncing": "Syncing", "components.Settings.tautulliApiKey": "API Key", From 796c65e97379a25a515ea624704e81b6cc57d925 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 20:18:37 +0100 Subject: [PATCH 14/29] feat(userList): add badges for linked Plex and Jellyfin/Emby users --- src/components/UserList/index.tsx | 65 +++++++++++++++++++++---------- src/i18n/locale/en.json | 2 + 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 7bdeef6050..5f488373b7 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -83,6 +83,8 @@ const messages = defineMessages('components.UserList', { sortRequests: 'Request Count', localLoginDisabled: 'The Enable Local Sign-In setting is currently disabled.', + linkedToPlex: 'Plex linked', + linkedToJellyfinEmby: 'Jellyfin/Emby linked', }); type Sort = 'created' | 'updated' | 'requests' | 'displayname'; @@ -679,27 +681,48 @@ const UserList = () => { )} - {user.userType === UserType.PLEX ? ( - - {intl.formatMessage(messages.plexuser)} - - ) : user.userType === UserType.LOCAL ? ( - - {intl.formatMessage(messages.localuser)} - - ) : user.userType === UserType.EMBY ? ( - - {intl.formatMessage(messages.mediaServerUser, { - mediaServerName: 'Emby', - })} - - ) : user.userType === UserType.JELLYFIN ? ( - - {intl.formatMessage(messages.mediaServerUser, { - mediaServerName: 'Jellyfin', - })} - - ) : null} +
+ {user.userType === UserType.PLEX ? ( + + {intl.formatMessage(messages.plexuser)} + + ) : user.userType === UserType.LOCAL ? ( + + {intl.formatMessage(messages.localuser)} + + ) : user.userType === UserType.EMBY ? ( + + {intl.formatMessage(messages.mediaServerUser, { + mediaServerName: 'Emby', + })} + + ) : user.userType === UserType.JELLYFIN ? ( + + {intl.formatMessage(messages.mediaServerUser, { + mediaServerName: 'Jellyfin', + })} + + ) : null} + {settings.currentSettings.mediaServerType === + MediaServerType.PLEX && + 'jellyfinUserId' in user && + (user as { jellyfinUserId?: string | null }) + .jellyfinUserId && ( + + {intl.formatMessage(messages.linkedToJellyfinEmby)} + + )} + {(settings.currentSettings.mediaServerType === + MediaServerType.JELLYFIN || + settings.currentSettings.mediaServerType === + MediaServerType.EMBY) && + 'plexId' in user && + (user as { plexId?: number | null }).plexId != null && ( + + {intl.formatMessage(messages.linkedToPlex)} + + )} +
{user.id === 1 diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 35a2c51f3c..7352024734 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1368,6 +1368,8 @@ "components.UserList.importfrommediaserver": "Import {mediaServerName} Users", "components.UserList.importfromplex": "Import Plex Users", "components.UserList.importfromplexerror": "Something went wrong while importing Plex users.", + "components.UserList.linkedToJellyfinEmby": "Jellyfin/Emby linked", + "components.UserList.linkedToPlex": "Plex linked", "components.UserList.localLoginDisabled": "The Enable Local Sign-In setting is currently disabled.", "components.UserList.localuser": "Local User", "components.UserList.mediaServerUser": "{mediaServerName} User", From 91dabdaad5dedc24b3988145052fadea8edf6b98 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 21:33:52 +0100 Subject: [PATCH 15/29] feat(settings): add a proper modal for switching --- .../Settings/SwitchMediaServerSection.tsx | 265 +++++++++++------- src/components/UserList/index.tsx | 27 +- src/i18n/locale/en.json | 7 + 3 files changed, 193 insertions(+), 106 deletions(-) diff --git a/src/components/Settings/SwitchMediaServerSection.tsx b/src/components/Settings/SwitchMediaServerSection.tsx index 6fb8ee2716..5c409aeee6 100644 --- a/src/components/Settings/SwitchMediaServerSection.tsx +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -1,28 +1,50 @@ -import ConfirmButton from '@app/components/Common/ConfirmButton'; +import Alert from '@app/components/Common/Alert'; +import Button from '@app/components/Common/Button'; +import Modal from '@app/components/Common/Modal'; import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; -import { useState } from 'react'; +import Link from 'next/link'; +import { Fragment, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; -type SwitchTargetServerType = 'jellyfin' | 'emby'; +type SwitchTargetServerType = 'jellyfin' | 'emby' | 'plex'; const messages = defineMessages('components.Settings', { switchMediaServerError: 'Something went wrong while switching media server. Please try again.', switchMediaServerSuccess: 'Media server switched. All users logged out. Restart the server, then sign in again.', + switchMediaServerStepsPlex: + '1) Have users link Jellyfin or Emby in {profile} → {linkedAccounts}.\n2) Optionally check {users} to see who has linked.\n3) Choose the target below and switch.', + switchMediaServerStepsJellyfinEmby: + '1) Configure Plex in the Plex tab.\n2) Have users link Plex in {profile} → {linkedAccounts}.\n3) Optionally check {users}.\n4) Choose the target below and switch.', + switchMediaServerWarning: + 'Everyone will be logged out. You must restart the server after switching.', + switchTargetAfter: 'New media server:', + switchMediaServerButton: 'Switch media server', + checkUsersLink: 'Users', }); const SwitchMediaServerSection = () => { const settings = useSettings(); const intl = useIntl(); const { addToast } = useToasts(); + const isPlex = + settings.currentSettings.mediaServerType === MediaServerType.PLEX; + const isJellyfin = + settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN; + const isEmby = + settings.currentSettings.mediaServerType === MediaServerType.EMBY; + + const [isModalOpen, setModalOpen] = useState(false); + const [isSubmitting, setSubmitting] = useState(false); const [switchTargetServerType, setSwitchTargetServerType] = - useState('jellyfin'); + useState(isPlex ? 'jellyfin' : 'plex'); if ( settings.currentSettings.mediaServerType === MediaServerType.NOT_CONFIGURED @@ -30,113 +52,146 @@ const SwitchMediaServerSection = () => { return null; } + const targetPayload = { targetServerType: switchTargetServerType }; + + const handleSwitch = async () => { + setSubmitting(true); + try { + const { data } = await axios.post<{ message?: string }>( + '/api/v1/settings/switch-media-server', + targetPayload + ); + addToast( + data?.message ?? intl.formatMessage(messages.switchMediaServerSuccess), + { appearance: 'success' } + ); + setModalOpen(false); + window.location.reload(); + } catch (err: unknown) { + const extracted = axios.isAxiosError(err) + ? (err.response?.data?.error ?? + err.response?.data?.message ?? + err.message) + : err instanceof Error + ? err.message + : null; + const message = + extracted != null && String(extracted).trim() !== '' + ? String(extracted) + : intl.formatMessage(messages.switchMediaServerError); + addToast(message, { appearance: 'error' }); + } finally { + setSubmitting(false); + } + }; + + const linkValues = { + profile: Profile, + linkedAccounts: ( + Linked accounts + ), + users: ( + + {intl.formatMessage(messages.checkUsersLink)} + + ), + }; + return (
-

+

-

- {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( - Profile - ), - linkedAccounts: ( - - Linked accounts - - ), - }} - /> - ) : ( - Profile - ), - linkedAccounts: ( - - Linked accounts - - ), - }} - /> - )} -

- {settings.currentSettings.mediaServerType === MediaServerType.PLEX && ( -
-
+
+ + +
+ +
); }; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 5f488373b7..6ca421148e 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -33,7 +33,7 @@ import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; -import { useIntl } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import validator from 'validator'; @@ -85,6 +85,8 @@ const messages = defineMessages('components.UserList', { 'The Enable Local Sign-In setting is currently disabled.', linkedToPlex: 'Plex linked', linkedToJellyfinEmby: 'Jellyfin/Emby linked', + switchMediaServerTip: + 'Users with "Plex linked" or "Jellyfin/Emby linked" can sign in after you switch media server in {generalSettings}.', }); type Sort = 'created' | 'updated' | 'requests' | 'displayname'; @@ -575,6 +577,29 @@ const UserList = () => {
+ {(settings.currentSettings.mediaServerType === MediaServerType.PLEX || + settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN || + settings.currentSettings.mediaServerType === MediaServerType.EMBY) && ( +

+ + + + ), + }} + /> +

+ )} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 7352024734..8e9f7436f6 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1131,6 +1131,7 @@ "components.Settings.blocklistedTagImportTitle": "Import Blocklisted Tag Configuration", "components.Settings.blocklistedTagsText": "Blocklisted Tags", "components.Settings.cancelscan": "Cancel Scan", + "components.Settings.checkUsersLink": "Users", "components.Settings.chooseProvider": "Choose metadata providers for different content types", "components.Settings.clearBlocklistedTagsConfirm": "Are you sure you want to clear the blocklisted tags?", "components.Settings.clickTest": "Click on the \"Test\" button to check connectivity with metadata providers", @@ -1236,8 +1237,13 @@ "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", "components.Settings.starttyping": "Starting typing to search.", + "components.Settings.switchMediaServerButton": "Switch media server", "components.Settings.switchMediaServerError": "Something went wrong while switching media server. Please try again.", + "components.Settings.switchMediaServerStepsJellyfinEmby": "1) Configure Plex in the Plex tab.\\n2) Have users link Plex in {profile} → {linkedAccounts}.\\n3) Optionally check {users}.\\n4) Choose the target below and switch.", + "components.Settings.switchMediaServerStepsPlex": "1) Have users link Jellyfin or Emby in {profile} → {linkedAccounts}.\\n2) Optionally check {users} to see who has linked.\\n3) Choose the target below and switch.", "components.Settings.switchMediaServerSuccess": "Media server switched. All users logged out. Restart the server, then sign in again.", + "components.Settings.switchMediaServerWarning": "Everyone will be logged out. You must restart the server after switching.", + "components.Settings.switchTargetAfter": "New media server:", "components.Settings.syncJellyfin": "Sync Libraries", "components.Settings.syncing": "Syncing", "components.Settings.tautulliApiKey": "API Key", @@ -1385,6 +1391,7 @@ "components.UserList.sortCreated": "Join Date", "components.UserList.sortDisplayName": "Display Name", "components.UserList.sortRequests": "Request Count", + "components.UserList.switchMediaServerTip": "Users with \"Plex linked\" or \"Jellyfin/Emby linked\" can sign in after you switch media server in {generalSettings}.", "components.UserList.totalrequests": "Requests", "components.UserList.user": "User", "components.UserList.usercreatedfailed": "Something went wrong while creating the user.", From e01a9048b646e37837a7dd321e29bd3639a56193 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 22:14:59 +0100 Subject: [PATCH 16/29] fix(settings): update deprecation messages and improve media server switch logic --- server/middleware/deprecation.ts | 2 +- server/routes/settings/index.ts | 45 ++- .../Settings/SwitchMediaServerSection.tsx | 290 ++++++++++++++---- src/i18n/locale/en.json | 15 +- 4 files changed, 283 insertions(+), 69 deletions(-) diff --git a/server/middleware/deprecation.ts b/server/middleware/deprecation.ts index a35839180d..2770f30ecb 100644 --- a/server/middleware/deprecation.ts +++ b/server/middleware/deprecation.ts @@ -20,7 +20,7 @@ export const deprecatedRoute = ({ }: DeprecationOptions) => { return (req: Request, res: Response, next: NextFunction) => { logger.warn( - `Deprecated API endpoint accessed: ${oldPath} → use ${newPath} instead`, + `Deprecated API endpoint accessed: ${oldPath} => use ${newPath} instead`, { label: 'API Deprecation', ip: req.ip, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 2515fe7c3c..1c4f5e6550 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -463,6 +463,45 @@ settingsRoutes.post( error: 'No media server is configured.', }); } + const target = body.targetServerType; + if (current === MediaServerType.PLEX) { + if (target !== 'jellyfin' && target !== 'emby') { + return res.status(400).json({ + error: + 'Specify targetServerType: "jellyfin" or "emby". Configure the connection in the Jellyfin/Emby settings tab first.', + }); + } + if (!settings.jellyfin?.ip) { + return res.status(400).json({ + error: + 'Jellyfin/Emby is not configured. Configure it in Settings first, then switch.', + }); + } + } else if ( + current === MediaServerType.JELLYFIN || + current === MediaServerType.EMBY + ) { + if (target !== 'plex' && target !== 'jellyfin' && target !== 'emby') { + return res.status(400).json({ + error: + 'Specify targetServerType: "plex", "jellyfin", or "emby". Configure the target in Settings first if switching to Plex.', + }); + } + if (target === 'plex') { + const admin = await getRepository(User).findOne({ + where: { id: 1 }, + select: { plexToken: true }, + }); + const plexConfigured = + (settings.plex?.name ?? settings.plex?.ip) || admin?.plexToken; + if (!plexConfigured) { + return res.status(400).json({ + error: + 'Plex is not configured. Configure Plex in Settings first, then switch.', + }); + } + } + } try { if (current === MediaServerType.PLEX) { const useEmby = body.targetServerType === 'emby'; @@ -526,7 +565,7 @@ settingsRoutes.post( const switchToPlex = targetJellyfinType === 'plex'; if (switchToJellyfin && current !== MediaServerType.JELLYFIN) { - // Emby → Jellyfin + // Emby => Jellyfin settings.main.mediaServerType = MediaServerType.JELLYFIN; settings.jellyfin = { name: '', @@ -580,7 +619,7 @@ settingsRoutes.post( } if (switchToEmby && current !== MediaServerType.EMBY) { - // Jellyfin → Emby + // Jellyfin => Emby settings.main.mediaServerType = MediaServerType.EMBY; settings.jellyfin = { name: '', @@ -633,7 +672,7 @@ settingsRoutes.post( } if (switchToPlex) { - // Jellyfin/Emby → Plex + // Jellyfin/Emby => Plex settings.main.mediaServerType = MediaServerType.PLEX; settings.jellyfin = { name: '', diff --git a/src/components/Settings/SwitchMediaServerSection.tsx b/src/components/Settings/SwitchMediaServerSection.tsx index 5c409aeee6..dc039e63e5 100644 --- a/src/components/Settings/SwitchMediaServerSection.tsx +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -6,11 +6,13 @@ import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { MediaServerType } from '@server/constants/server'; +import type { JellyfinSettings, PlexSettings } from '@server/lib/settings'; import axios from 'axios'; import Link from 'next/link'; import { Fragment, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; type SwitchTargetServerType = 'jellyfin' | 'emby' | 'plex'; @@ -19,14 +21,31 @@ const messages = defineMessages('components.Settings', { 'Something went wrong while switching media server. Please try again.', switchMediaServerSuccess: 'Media server switched. All users logged out. Restart the server, then sign in again.', - switchMediaServerStepsPlex: - '1) Have users link Jellyfin or Emby in {profile} → {linkedAccounts}.\n2) Optionally check {users} to see who has linked.\n3) Choose the target below and switch.', - switchMediaServerStepsJellyfinEmby: - '1) Configure Plex in the Plex tab.\n2) Have users link Plex in {profile} → {linkedAccounts}.\n3) Optionally check {users}.\n4) Choose the target below and switch.', + switchMediaServerStep1Plex: + '1) Configure Jellyfin or Emby in the Jellyfin settings tab.', + switchMediaServerStep2Plex: + '2) Have users link Jellyfin or Emby in {profile} => {linkedAccounts}.', + switchMediaServerStep3Plex: + '3) Optionally check {users} to see who has linked.', + switchMediaServerStep4Plex: '4) Choose the target below and switch.', + switchMediaServerStep1JellyfinEmby: + '1) Configure Plex in the Plex settings tab.', + switchMediaServerStep2JellyfinEmby: + '2) Have users link Plex in {profile} => {linkedAccounts}.', + switchMediaServerStep3JellyfinEmby: '3) Optionally check {users}.', + switchMediaServerStep4JellyfinEmby: '4) Choose the target below and switch.', + switchMediaServerStep1JellyfinEmbyToOther: + '1) Choose the target below and switch.', + switchMediaServerStep2JellyfinEmbyToOther: '2) Restart the server.', + switchMediaServerStep3JellyfinEmbyToOther: + '3) Reconfigure the connection in the Jellyfin settings tab (same tab, new server type).', + switchMediaServerStep4JellyfinEmbyToOther: + '4) Have users sign in with the new media server.', switchMediaServerWarning: 'Everyone will be logged out. You must restart the server after switching.', switchTargetAfter: 'New media server:', switchMediaServerButton: 'Switch media server', + switchToPlex: 'Switch to Plex', checkUsersLink: 'Users', }); @@ -41,10 +60,20 @@ const SwitchMediaServerSection = () => { const isEmby = settings.currentSettings.mediaServerType === MediaServerType.EMBY; + const { data: plexData } = useSWR( + isJellyfin || isEmby ? '/api/v1/settings/plex' : null + ); + const { data: jellyfinData } = useSWR( + isPlex ? '/api/v1/settings/jellyfin' : null + ); + + const plexConfigured = Boolean(plexData?.name || plexData?.ip); + const jellyfinConfigured = Boolean(jellyfinData?.ip); + const [isModalOpen, setModalOpen] = useState(false); const [isSubmitting, setSubmitting] = useState(false); const [switchTargetServerType, setSwitchTargetServerType] = - useState(isPlex ? 'jellyfin' : 'plex'); + useState('jellyfin'); if ( settings.currentSettings.mediaServerType === MediaServerType.NOT_CONFIGURED @@ -52,7 +81,25 @@ const SwitchMediaServerSection = () => { return null; } - const targetPayload = { targetServerType: switchTargetServerType }; + const canSwitchToPlex = (isJellyfin || isEmby) && plexConfigured; + const canSwitchToJellyfinEmby = isPlex && jellyfinConfigured; + const hasValidTarget = + canSwitchToJellyfinEmby || canSwitchToPlex || isJellyfin || isEmby; + if (!hasValidTarget) { + return null; + } + + const validTargets: SwitchTargetServerType[] = isPlex + ? (['jellyfin', 'emby'] as const) + : isJellyfin + ? (['emby', ...(plexConfigured ? (['plex'] as const) : [])] as const) + : (['jellyfin', ...(plexConfigured ? (['plex'] as const) : [])] as const); + const effectiveTarget = validTargets.includes(switchTargetServerType) + ? switchTargetServerType + : validTargets[0]; + const targetPayload: { targetServerType: SwitchTargetServerType } = { + targetServerType: effectiveTarget, + }; const handleSwitch = async () => { setSubmitting(true); @@ -103,10 +150,17 @@ const SwitchMediaServerSection = () => { return (
{ leaveTo="opacity-0" > !isSubmitting && setModalOpen(false)} onOk={handleSwitch} - okText={intl.formatMessage(messages.switchMediaServerButton)} + okText={ + validTargets.length === 1 && validTargets[0] === 'plex' + ? intl.formatMessage(messages.switchToPlex) + : intl.formatMessage(messages.switchMediaServerButton) + } okButtonType="danger" cancelText={intl.formatMessage(globalMessages.cancel)} loading={isSubmitting} okDisabled={isSubmitting} > -

+

{isPlex ? ( - + <> +

+ +

+

+ +

+

+ +

+

+ +

+ + ) : (isJellyfin || isEmby) && effectiveTarget !== 'plex' ? ( + <> +

+ +

+

+ +

+

+ +

+

+ +

+ ) : ( - + <> +

+ +

+

+ +

+

+ +

+

+ +

+ )} -

+
-
- - + setSwitchTargetServerType( + e.target.value as SwitchTargetServerType + ) + } + disabled={isSubmitting} + > + {validTargets.map((t) => ( + + ))} + + ) : ( + + {effectiveTarget === 'plex' + ? 'Plex' + : effectiveTarget === 'jellyfin' + ? 'Jellyfin' + : 'Emby'} + )} - {(isJellyfin || isEmby) && ( - <> - - {isEmby && } - {isJellyfin && } - - )} - -
+
+ )} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 8e9f7436f6..91115b0413 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1239,11 +1239,22 @@ "components.Settings.starttyping": "Starting typing to search.", "components.Settings.switchMediaServerButton": "Switch media server", "components.Settings.switchMediaServerError": "Something went wrong while switching media server. Please try again.", - "components.Settings.switchMediaServerStepsJellyfinEmby": "1) Configure Plex in the Plex tab.\\n2) Have users link Plex in {profile} → {linkedAccounts}.\\n3) Optionally check {users}.\\n4) Choose the target below and switch.", - "components.Settings.switchMediaServerStepsPlex": "1) Have users link Jellyfin or Emby in {profile} → {linkedAccounts}.\\n2) Optionally check {users} to see who has linked.\\n3) Choose the target below and switch.", + "components.Settings.switchMediaServerStep1JellyfinEmby": "1) Configure Plex in the Plex settings tab.", + "components.Settings.switchMediaServerStep1JellyfinEmbyToOther": "1) Choose the target below and switch.", + "components.Settings.switchMediaServerStep1Plex": "1) Configure Jellyfin or Emby in the Jellyfin settings tab.", + "components.Settings.switchMediaServerStep2JellyfinEmby": "2) Have users link Plex in {profile} => {linkedAccounts}.", + "components.Settings.switchMediaServerStep2JellyfinEmbyToOther": "2) Restart the server.", + "components.Settings.switchMediaServerStep2Plex": "2) Have users link Jellyfin or Emby in {profile} => {linkedAccounts}.", + "components.Settings.switchMediaServerStep3JellyfinEmby": "3) Optionally check {users}.", + "components.Settings.switchMediaServerStep3JellyfinEmbyToOther": "3) Reconfigure the connection in the Jellyfin settings tab (same tab, new server type).", + "components.Settings.switchMediaServerStep3Plex": "3) Optionally check {users} to see who has linked.", + "components.Settings.switchMediaServerStep4JellyfinEmby": "4) Choose the target below and switch.", + "components.Settings.switchMediaServerStep4JellyfinEmbyToOther": "4) Have users sign in with the new media server.", + "components.Settings.switchMediaServerStep4Plex": "4) Choose the target below and switch.", "components.Settings.switchMediaServerSuccess": "Media server switched. All users logged out. Restart the server, then sign in again.", "components.Settings.switchMediaServerWarning": "Everyone will be logged out. You must restart the server after switching.", "components.Settings.switchTargetAfter": "New media server:", + "components.Settings.switchToPlex": "Switch to Plex", "components.Settings.syncJellyfin": "Sync Libraries", "components.Settings.syncing": "Syncing", "components.Settings.tautulliApiKey": "API Key", From c9d95a62eebe3b658abe88852a1192b5f31012ca Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Feb 2026 22:30:05 +0100 Subject: [PATCH 17/29] feat(settings): make code more dry --- seerr-api.yml | 10 + server/routes/auth.ts | 1 - server/routes/settings/index.ts | 276 ++++++------------ .../Settings/SwitchMediaServerSection.tsx | 215 +++++--------- 4 files changed, 179 insertions(+), 323 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index 299db62afc..bcdcafa6e0 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -2295,6 +2295,16 @@ paths: summary: Switch media server tags: - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + targetServerType: + type: string + enum: [jellyfin, emby, plex] + description: Target media server type. Required when switching from Plex (jellyfin or emby) or from Jellyfin/Emby (plex, jellyfin, or emby). responses: '200': description: Media server cleared diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 5aa76a25d6..8c61c03953 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -60,7 +60,6 @@ authRoutes.post('/plex', async (req, res, next) => { const mediaServerType = settings.main.mediaServerType; - // When main server is Jellyfin/Emby, allow admin to store Plex token for settings (linking) if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1c4f5e6550..ac686b3e6c 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -451,6 +451,27 @@ settingsRoutes.post('/jellyfin/sync', (req, res) => { return res.status(200).json(jellyfinFullScanner.status()); }); +const EMPTY_PLEX_SETTINGS = { + name: '', + ip: '', + port: 32400, + useSsl: false, + libraries: [] as never[], +}; + +const EMPTY_JELLYFIN_SETTINGS = { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [] as never[], + serverId: '', + apiKey: '', +}; + settingsRoutes.post( '/switch-media-server', isAuthenticated(Permission.ADMIN), @@ -458,12 +479,14 @@ settingsRoutes.post( const settings = getSettings(); const current = settings.main.mediaServerType; const body = (req.body as { targetServerType?: string }) ?? {}; + const target = body.targetServerType; + if (current === MediaServerType.NOT_CONFIGURED) { return res.status(400).json({ error: 'No media server is configured.', }); } - const target = body.targetServerType; + if (current === MediaServerType.PLEX) { if (target !== 'jellyfin' && target !== 'emby') { return res.status(400).json({ @@ -502,26 +525,20 @@ settingsRoutes.post( } } } + try { if (current === MediaServerType.PLEX) { - const useEmby = body.targetServerType === 'emby'; + const useEmby = target === 'emby'; settings.main.mediaServerType = useEmby ? MediaServerType.EMBY : MediaServerType.JELLYFIN; - settings.plex = { - name: '', - ip: '', - port: 32400, - useSsl: false, - libraries: [], - }; - const userRepository = getRepository(User); - await userRepository + settings.plex = { ...EMPTY_PLEX_SETTINGS }; + await getRepository(User) .createQueryBuilder() .update(User) .set({ plexId: null, plexUsername: null, plexToken: null }) .execute(); - await userRepository + await getRepository(User) .createQueryBuilder() .update(User) .set({ @@ -529,15 +546,13 @@ settingsRoutes.post( }) .where('user.jellyfinUserId IS NOT NULL') .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository + await getRepository(Media) .createQueryBuilder() .update(Media) .set({ ratingKey: null, ratingKey4k: null }) .where('media.ratingKey IS NOT NULL OR media.ratingKey4k IS NOT NULL') .execute(); - const watchlistRepository = getRepository(Watchlist); - await watchlistRepository + await getRepository(Watchlist) .createQueryBuilder() .update(Watchlist) .set({ ratingKey: '' }) @@ -555,184 +570,81 @@ settingsRoutes.post( ? 'Switched to Emby. All users have been logged out. Restart the server, then sign in with the new media server.' : 'Switched to Jellyfin. All users have been logged out. Restart the server, then sign in with the new media server.', }); - } else if ( + } + + if ( current === MediaServerType.JELLYFIN || current === MediaServerType.EMBY ) { - const targetJellyfinType = body.targetServerType; - const switchToJellyfin = targetJellyfinType === 'jellyfin'; - const switchToEmby = targetJellyfinType === 'emby'; - const switchToPlex = targetJellyfinType === 'plex'; - - if (switchToJellyfin && current !== MediaServerType.JELLYFIN) { - // Emby => Jellyfin - settings.main.mediaServerType = MediaServerType.JELLYFIN; - settings.jellyfin = { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }; - const userRepository = getRepository(User); - await userRepository - .createQueryBuilder() - .update(User) - .set({ - jellyfinUserId: null, - jellyfinUsername: null, - jellyfinAuthToken: null, - jellyfinDeviceId: null, - }) - .execute(); - await userRepository - .createQueryBuilder() - .update(User) - .set({ userType: UserType.JELLYFIN }) - .where('user.jellyfinUserId IS NOT NULL') - .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository - .createQueryBuilder() - .update(Media) - .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) - .where( - 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' - ) - .execute(); - await settings.save(); - await getRepository(Session) - .createQueryBuilder() - .delete() - .from(Session) - .execute(); - startJobs(); - return res.status(200).json({ - message: - 'Switched to Jellyfin. All users have been logged out. Restart the server, then reconfigure and sign in with the new media server.', - }); - } - - if (switchToEmby && current !== MediaServerType.EMBY) { - // Jellyfin => Emby - settings.main.mediaServerType = MediaServerType.EMBY; - settings.jellyfin = { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }; - const userRepository = getRepository(User); - await userRepository - .createQueryBuilder() - .update(User) - .set({ - jellyfinUserId: null, - jellyfinUsername: null, - jellyfinAuthToken: null, - jellyfinDeviceId: null, - }) - .execute(); - await userRepository - .createQueryBuilder() - .update(User) - .set({ userType: UserType.EMBY }) - .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository - .createQueryBuilder() - .update(Media) - .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) - .where( - 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' - ) - .execute(); - await settings.save(); - await getRepository(Session) - .createQueryBuilder() - .delete() - .from(Session) - .execute(); - startJobs(); - return res.status(200).json({ - message: - 'Switched to Emby. All users have been logged out. Restart the server, then reconfigure and sign in with the new media server.', + const newType = + target === 'plex' + ? MediaServerType.PLEX + : target === 'emby' + ? MediaServerType.EMBY + : MediaServerType.JELLYFIN; + const newUserType = + target === 'plex' + ? UserType.PLEX + : target === 'emby' + ? UserType.EMBY + : UserType.JELLYFIN; + const serverName = + target === 'plex' ? 'Plex' : target === 'emby' ? 'Emby' : 'Jellyfin'; + + if ( + (target === 'jellyfin' && current === MediaServerType.JELLYFIN) || + (target === 'emby' && current === MediaServerType.EMBY) + ) { + return res.status(400).json({ + error: `Already using ${serverName}. Choose a different target.`, }); } - if (switchToPlex) { - // Jellyfin/Emby => Plex - settings.main.mediaServerType = MediaServerType.PLEX; - settings.jellyfin = { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }; - const userRepository = getRepository(User); - await userRepository - .createQueryBuilder() - .update(User) - .set({ - jellyfinUserId: null, - jellyfinUsername: null, - jellyfinAuthToken: null, - jellyfinDeviceId: null, - }) - .execute(); - await userRepository - .createQueryBuilder() - .update(User) - .set({ userType: UserType.PLEX }) - .execute(); - const mediaRepository = getRepository(Media); - await mediaRepository - .createQueryBuilder() - .update(Media) - .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) - .where( - 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' - ) - .execute(); - await settings.save(); - await getRepository(Session) - .createQueryBuilder() - .delete() - .from(Session) - .execute(); - startJobs(); - return res.status(200).json({ - message: - 'Switched to Plex. All users have been logged out. Restart the server, then sign in with the new media server.', - }); - } + settings.main.mediaServerType = newType; + settings.jellyfin = { ...EMPTY_JELLYFIN_SETTINGS }; + await getRepository(User) + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .execute(); + await getRepository(User) + .createQueryBuilder() + .update(User) + .set({ userType: newUserType }) + .execute(); + await getRepository(Media) + .createQueryBuilder() + .update(Media) + .set({ jellyfinMediaId: null, jellyfinMediaId4k: null }) + .where( + 'media.jellyfinMediaId IS NOT NULL OR media.jellyfinMediaId4k IS NOT NULL' + ) + .execute(); + await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); + startJobs(); - return res.status(400).json({ - error: - 'Specify targetServerType: "plex", "jellyfin", or "emby" to switch media server.', + const reconfigure = + target === 'jellyfin' || target === 'emby' + ? ' Restart the server, then reconfigure and sign in with the new media server.' + : ' Restart the server, then sign in with the new media server.'; + return res.status(200).json({ + message: `Switched to ${serverName}. All users have been logged out.${reconfigure}`, }); } } catch (e) { logger.error('Switch media server failed', { label: 'Settings', - errorMessage: e.message, + errorMessage: (e as Error).message, }); return next({ status: 500, message: 'Failed to switch media server.' }); } diff --git a/src/components/Settings/SwitchMediaServerSection.tsx b/src/components/Settings/SwitchMediaServerSection.tsx index dc039e63e5..d0652635eb 100644 --- a/src/components/Settings/SwitchMediaServerSection.tsx +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -16,6 +16,14 @@ import useSWR from 'swr'; type SwitchTargetServerType = 'jellyfin' | 'emby' | 'plex'; +function getTargetLabel(target: SwitchTargetServerType): string { + return target === 'plex' + ? 'Plex' + : target === 'jellyfin' + ? 'Jellyfin' + : 'Emby'; +} + const messages = defineMessages('components.Settings', { switchMediaServerError: 'Something went wrong while switching media server. Please try again.', @@ -49,6 +57,43 @@ const messages = defineMessages('components.Settings', { checkUsersLink: 'Users', }); +type StepsVariant = 'plex' | 'jellyfinEmbyToPlex' | 'jellyfinEmbyToOther'; + +const STEP_KEYS: Record< + StepsVariant, + [ + keyof typeof messages, + keyof typeof messages, + keyof typeof messages, + keyof typeof messages, + ] +> = { + plex: [ + 'switchMediaServerStep1Plex', + 'switchMediaServerStep2Plex', + 'switchMediaServerStep3Plex', + 'switchMediaServerStep4Plex', + ], + jellyfinEmbyToPlex: [ + 'switchMediaServerStep1JellyfinEmby', + 'switchMediaServerStep2JellyfinEmby', + 'switchMediaServerStep3JellyfinEmby', + 'switchMediaServerStep4JellyfinEmby', + ], + jellyfinEmbyToOther: [ + 'switchMediaServerStep1JellyfinEmbyToOther', + 'switchMediaServerStep2JellyfinEmbyToOther', + 'switchMediaServerStep3JellyfinEmbyToOther', + 'switchMediaServerStep4JellyfinEmbyToOther', + ], +}; + +const STEP_LINK_VALUES_INDEX: Record = { + plex: [1, 2], + jellyfinEmbyToPlex: [1, 2], + jellyfinEmbyToOther: [], +}; + const SwitchMediaServerSection = () => { const settings = useSettings(); const intl = useIntl(); @@ -97,9 +142,13 @@ const SwitchMediaServerSection = () => { const effectiveTarget = validTargets.includes(switchTargetServerType) ? switchTargetServerType : validTargets[0]; - const targetPayload: { targetServerType: SwitchTargetServerType } = { - targetServerType: effectiveTarget, - }; + const targetPayload = { targetServerType: effectiveTarget }; + const showPlexOnly = validTargets.length === 1 && validTargets[0] === 'plex'; + const stepsVariant: StepsVariant = isPlex + ? 'plex' + : (isJellyfin || isEmby) && effectiveTarget !== 'plex' + ? 'jellyfinEmbyToOther' + : 'jellyfinEmbyToPlex'; const handleSwitch = async () => { setSubmitting(true); @@ -115,17 +164,12 @@ const SwitchMediaServerSection = () => { setModalOpen(false); window.location.reload(); } catch (err: unknown) { - const extracted = axios.isAxiosError(err) - ? (err.response?.data?.error ?? - err.response?.data?.message ?? - err.message) - : err instanceof Error - ? err.message - : null; const message = - extracted != null && String(extracted).trim() !== '' - ? String(extracted) - : intl.formatMessage(messages.switchMediaServerError); + axios.isAxiosError(err) && err.response?.data?.message + ? String(err.response.data.message) + : axios.isAxiosError(err) && err.response?.data?.error + ? String(err.response.data.error) + : intl.formatMessage(messages.switchMediaServerError); addToast(message, { appearance: 'error' }); } finally { setSubmitting(false); @@ -150,7 +194,7 @@ const SwitchMediaServerSection = () => { return (