feat(settings): make code more dry

This commit is contained in:
0xsysr3ll
2026-02-22 22:30:05 +01:00
parent 16d4810b37
commit be2ffbbe18
4 changed files with 178 additions and 322 deletions

View File

@@ -2303,6 +2303,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

View File

@@ -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

View File

@@ -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';
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 (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 (
(target === 'jellyfin' && current === MediaServerType.JELLYFIN) ||
(target === 'emby' && current === MediaServerType.EMBY)
) {
return res.status(400).json({
error: `Already using ${serverName}. Choose a different target.`,
});
}
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.',
});
}
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();
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.',
});
}
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.' });
}

View File

@@ -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<StepsVariant, number[]> = {
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 (
<div className="mt-10 border-t border-gray-700 pt-8">
<Button buttonType="danger" onClick={() => setModalOpen(true)}>
{validTargets.length === 1 && validTargets[0] === 'plex' ? (
{showPlexOnly ? (
<FormattedMessage
id="components.Settings.switchToPlex"
defaultMessage={messages.switchToPlex.defaultMessage}
@@ -175,14 +219,14 @@ const SwitchMediaServerSection = () => {
>
<Modal
title={
validTargets.length === 1 && validTargets[0] === 'plex'
showPlexOnly
? intl.formatMessage(messages.switchToPlex)
: intl.formatMessage(messages.switchMediaServerButton)
}
onCancel={() => !isSubmitting && setModalOpen(false)}
onOk={handleSwitch}
okText={
validTargets.length === 1 && validTargets[0] === 'plex'
showPlexOnly
? intl.formatMessage(messages.switchToPlex)
: intl.formatMessage(messages.switchMediaServerButton)
}
@@ -192,120 +236,19 @@ const SwitchMediaServerSection = () => {
okDisabled={isSubmitting}
>
<div className="space-y-1 text-gray-300">
{isPlex ? (
<>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep1Plex"
defaultMessage={
messages.switchMediaServerStep1Plex.defaultMessage
}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep2Plex"
defaultMessage={
messages.switchMediaServerStep2Plex.defaultMessage
}
values={linkValues}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep3Plex"
defaultMessage={
messages.switchMediaServerStep3Plex.defaultMessage
}
values={linkValues}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep4Plex"
defaultMessage={
messages.switchMediaServerStep4Plex.defaultMessage
}
/>
</p>
</>
) : (isJellyfin || isEmby) && effectiveTarget !== 'plex' ? (
<>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep1JellyfinEmbyToOther"
defaultMessage={
messages.switchMediaServerStep1JellyfinEmbyToOther
.defaultMessage
}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep2JellyfinEmbyToOther"
defaultMessage={
messages.switchMediaServerStep2JellyfinEmbyToOther
.defaultMessage
}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep3JellyfinEmbyToOther"
defaultMessage={
messages.switchMediaServerStep3JellyfinEmbyToOther
.defaultMessage
}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep4JellyfinEmbyToOther"
defaultMessage={
messages.switchMediaServerStep4JellyfinEmbyToOther
.defaultMessage
}
/>
</p>
</>
) : (
<>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep1JellyfinEmby"
defaultMessage={
messages.switchMediaServerStep1JellyfinEmby.defaultMessage
}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep2JellyfinEmby"
defaultMessage={
messages.switchMediaServerStep2JellyfinEmby.defaultMessage
}
values={linkValues}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep3JellyfinEmby"
defaultMessage={
messages.switchMediaServerStep3JellyfinEmby.defaultMessage
}
values={linkValues}
/>
</p>
<p className="m-0">
<FormattedMessage
id="components.Settings.switchMediaServerStep4JellyfinEmby"
defaultMessage={
messages.switchMediaServerStep4JellyfinEmby.defaultMessage
}
/>
</p>
</>
)}
{STEP_KEYS[stepsVariant].map((key, i) => (
<p key={key} className="m-0">
<FormattedMessage
id={`components.Settings.${key}`}
defaultMessage={messages[key].defaultMessage}
values={
STEP_LINK_VALUES_INDEX[stepsVariant].includes(i)
? linkValues
: undefined
}
/>
</p>
))}
</div>
<div className="mt-3">
<Alert
@@ -335,21 +278,13 @@ const SwitchMediaServerSection = () => {
>
{validTargets.map((t) => (
<option key={t} value={t}>
{t === 'plex'
? 'Plex'
: t === 'jellyfin'
? 'Jellyfin'
: 'Emby'}
{getTargetLabel(t)}
</option>
))}
</select>
) : (
<span className="text-sm font-medium text-white">
{effectiveTarget === 'plex'
? 'Plex'
: effectiveTarget === 'jellyfin'
? 'Jellyfin'
: 'Emby'}
{getTargetLabel(effectiveTarget)}
</span>
)}
</div>