diff --git a/seerr-api.yml b/seerr-api.yml index 99ef16cc3c..bcdcafa6e0 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -2290,6 +2290,44 @@ paths: application/json: schema: $ref: '#/components/schemas/JellyfinSettings' + /settings/switch-media-server: + post: + 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 + 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/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/auth.ts b/server/routes/auth.ts index d625d85ec7..138b60e951 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -58,6 +58,47 @@ authRoutes.post('/plex', async (req, res, next) => { }); } + const mediaServerType = settings.main.mediaServerType; + + if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + if (!req.user) { + return next({ + status: 401, + 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(); + 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 +451,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 +470,10 @@ 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; if (user.username === account.User.Name) { user.username = ''; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b5746595..ee356a0aef 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -3,17 +3,21 @@ 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 { 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'; 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'; @@ -38,6 +42,7 @@ import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; +import { IsNull, Not } from 'typeorm'; import { URL } from 'url'; import metadataRoutes from './metadata'; import notificationRoutes from './notifications'; @@ -118,15 +123,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 +174,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 +217,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 +451,214 @@ 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), + async (req, res, next) => { + 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.', + }); + } + + 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 = target === 'emby'; + settings.main.mediaServerType = useEmby + ? MediaServerType.EMBY + : MediaServerType.JELLYFIN; + settings.plex = { ...EMPTY_PLEX_SETTINGS }; + await getRepository(User) + .createQueryBuilder() + .update(User) + .set({ plexId: null, plexUsername: null, plexToken: null }) + .execute(); + await getRepository(User).update( + { jellyfinUserId: Not(IsNull()) }, + { + userType: useEmby ? UserType.EMBY : UserType.JELLYFIN, + } + ); + await getRepository(Media).update( + { ratingKey: Not(IsNull()) }, + { ratingKey: null, ratingKey4k: null } + ); + await getRepository(Media).update( + { ratingKey4k: Not(IsNull()) }, + { ratingKey: null, ratingKey4k: null } + ); + await getRepository(Watchlist).update( + { ratingKey: Not('') }, + { ratingKey: '' } + ); + await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); + res.status(200).json({ + message: useEmby + ? '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.', + }); + setImmediate(() => startJobs()); + return; + } + + if ( + current === MediaServerType.JELLYFIN || + current === MediaServerType.EMBY + ) { + 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.`, + }); + } + + settings.main.mediaServerType = newType; + if (target === 'plex') { + settings.jellyfin = { ...EMPTY_JELLYFIN_SETTINGS }; + } + + const userRepository = getRepository(User); + const userTypeUpdateQuery = userRepository + .createQueryBuilder() + .update(User) + .set({ userType: newUserType }); + + if (target === 'plex') { + userTypeUpdateQuery.where('plexId IS NOT NULL'); + } else { + userTypeUpdateQuery.where('jellyfinUserId IS NOT NULL'); + } + + await userTypeUpdateQuery.execute(); + + await userRepository + .createQueryBuilder() + .update(User) + .set({ + jellyfinUserId: null, + jellyfinUsername: null, + jellyfinAuthToken: null, + jellyfinDeviceId: null, + }) + .execute(); + await getRepository(Media).update( + { jellyfinMediaId: Not(IsNull()) }, + { jellyfinMediaId: null, jellyfinMediaId4k: null } + ); + await getRepository(Media).update( + { jellyfinMediaId4k: Not(IsNull()) }, + { jellyfinMediaId: null, jellyfinMediaId4k: null } + ); + await settings.save(); + await getRepository(Session) + .createQueryBuilder() + .delete() + .from(Session) + .execute(); + res.status(200).json({ + message: `Switched to ${serverName}. All users have been logged out. Restart the server, then sign in with the new media server.`, + }); + setImmediate(() => startJobs()); + return; + } + } catch (e) { + logger.error('Switch media server failed', { + label: 'Settings', + errorMessage: (e as Error).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..0d1ca6aa6e 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -275,26 +275,41 @@ 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) { + // Jellyfin users often have no email. + if ( + isMainPlex && + user.email?.toLowerCase() !== account.email?.toLowerCase() + ) { return res.status(422).json({ message: 'This Plex account is registered under a different email address.', @@ -302,7 +317,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 +336,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,28 +393,32 @@ 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 - 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', }); } - 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'); @@ -418,24 +442,37 @@ 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; - - // 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; @@ -468,14 +505,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 { diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 3cf8229f04..81994d7e69 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; } @@ -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) ) { 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..a16ccd7cc8 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -4,8 +4,11 @@ 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 { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { isValidURL } from '@app/utils/urlValidationHelper'; @@ -15,6 +18,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 +85,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 +139,8 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { ); const intl = useIntl(); const { addToast, removeToast } = useToasts(); + const settings = useSettings(); + const { revalidate: revalidateUser } = useUser(); const PlexSettingsSchema = Yup.object().shape({ hostname: Yup.string() @@ -332,11 +341,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(); + revalidateUser(); + } 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..dca26d04ff --- /dev/null +++ b/src/components/Settings/SwitchMediaServerSection.tsx @@ -0,0 +1,292 @@ +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 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'; + +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.', + 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) In the Jellyfin settings tab, reconfigure the connection for your new server (host, API key, etc.) and save.', + switchMediaServerStep2JellyfinEmbyToOther: + '2) Choose the target below and switch.', + switchMediaServerStep3JellyfinEmbyToOther: '3) Restart the server.', + switchMediaServerStep4JellyfinEmbyToOther: + '4) 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', +}); + +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(); + 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 { 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('jellyfin'); + + if ( + settings.currentSettings.mediaServerType === MediaServerType.NOT_CONFIGURED + ) { + return null; + } + + 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: 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); + try { + await axios.post<{ message?: string }>( + '/api/v1/settings/switch-media-server', + targetPayload + ); + setModalOpen(false); + window.location.reload(); + } catch (err: unknown) { + const message = + 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); + } + }; + + const linkValues = { + profile: Profile, + linkedAccounts: ( + Linked accounts + ), + users: ( + + {intl.formatMessage(messages.checkUsersLink)} + + ), + }; + + return ( +
+ + + + !isSubmitting && setModalOpen(false)} + onOk={handleSwitch} + okText={ + showPlexOnly + ? intl.formatMessage(messages.switchToPlex) + : intl.formatMessage(messages.switchMediaServerButton) + } + okButtonType="danger" + cancelText={intl.formatMessage(globalMessages.cancel)} + loading={isSubmitting} + okDisabled={isSubmitting} + > +
+ {STEP_KEYS[stepsVariant].map((key, i) => ( +

+ +

+ ))} +
+
+ +
+ {validTargets.length > 0 && ( +
+ + + + {validTargets.length > 1 ? ( + + ) : ( + + {getTargetLabel(effectiveTarget)} + + )} +
+ )} +
+
+
+ ); +}; + +export default SwitchMediaServerSection; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 7bdeef6050..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'; @@ -83,6 +83,10 @@ 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', + 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'; @@ -573,6 +577,29 @@ const UserList = () => {
+ {(settings.currentSettings.mediaServerType === MediaServerType.PLEX || + settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN || + settings.currentSettings.mediaServerType === MediaServerType.EMBY) && ( +

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

+ )} @@ -679,27 +706,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/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index 9fcbe3cc3a..cd1f7db7f6 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -70,7 +70,7 @@ const UserLinkedAccountsSettings = () => { const accounts: LinkedAccount[] = useMemo(() => { const accounts: LinkedAccount[] = []; if (!user) return accounts; - if (user.userType === UserType.PLEX && user.plexUsername) + if (user.plexUsername) accounts.push({ type: LinkedAccountType.Plex, username: user.plexUsername, @@ -80,7 +80,10 @@ const UserLinkedAccountsSettings = () => { 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..4f8107e470 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", @@ -1204,6 +1205,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,10 +1232,28 @@ "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", "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.switchMediaServerStep1JellyfinEmby": "1) Configure Plex in the Plex settings tab.", + "components.Settings.switchMediaServerStep1JellyfinEmbyToOther": "1) In the Jellyfin settings tab, reconfigure the connection for your new server (host, API key, etc.) and save.", + "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) Choose the target below and switch.", + "components.Settings.switchMediaServerStep2Plex": "2) Have users link Jellyfin or Emby in {profile} => {linkedAccounts}.", + "components.Settings.switchMediaServerStep3JellyfinEmby": "3) Optionally check {users}.", + "components.Settings.switchMediaServerStep3JellyfinEmbyToOther": "3) Restart the server.", + "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) Sign in with the new media server.", + "components.Settings.switchMediaServerStep4Plex": "4) Choose the target below and switch.", + "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", @@ -1364,6 +1384,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", @@ -1379,6 +1401,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.",