From f3b9b873ed7f95f183f4ac1774dd4aa83367790a Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Thu, 24 Apr 2025 22:16:13 +0200 Subject: [PATCH] feat: add support for importing Plex profiles - Extend API endpoint to fetch and import Plex profiles - Implement duplicate detection between users and profiles - Add UI to display and select Plex profiles for import - Improve error handling for duplicate records - Enhance success/error messages with profile-specific text --- server/routes/settings/index.ts | 38 +- server/routes/user/index.ts | 153 +++++- src/components/Login/index.tsx | 6 +- src/components/UserList/PlexImportModal.tsx | 581 ++++++++++++++++---- src/i18n/locale/en.json | 18 +- 5 files changed, 639 insertions(+), 157 deletions(-) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index d74d328f..5411cc6d 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -471,13 +471,13 @@ settingsRoutes.get( async (req, res, next) => { const userRepository = getRepository(User); const qb = userRepository.createQueryBuilder('user'); - try { const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); const plexApi = new PlexTvAPI(admin.plexToken ?? ''); + const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map( (user) => user.$ ).filter((user) => user.email); @@ -503,7 +503,7 @@ settingsRoutes.get( plexUsers.map(async (plexUser) => { if ( !existingUsers.find( - (user) => + (user: User) => user.plexId === parseInt(plexUser.id) || user.email === plexUser.email.toLowerCase() ) && @@ -513,16 +513,36 @@ settingsRoutes.get( } }) ); - - return res.status(200).json(sortBy(unimportedPlexUsers, 'username')); - } catch (e) { - logger.error('Something went wrong getting unimported Plex users', { - label: 'API', - errorMessage: e.message, + const profiles = await plexApi.getProfiles(); + const existingProfileUsers = await userRepository.find({ + where: { + isPlexProfile: true, + }, }); + + const unimportedProfiles = profiles.filter( + (profile) => + !profile.isMainUser && + !existingProfileUsers.some( + (user: User) => user.plexProfileId === profile.id + ) + ); + + return res.status(200).json({ + users: sortBy(unimportedPlexUsers, 'username'), + profiles: unimportedProfiles, + }); + } catch (e) { + logger.error( + 'Something went wrong getting unimported Plex users and profiles', + { + label: 'API', + errorMessage: e.message, + } + ); next({ status: 500, - message: 'Unable to retrieve unimported Plex users.', + message: 'Unable to retrieve unimported Plex users and profiles.', }); } } diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index f817a9cb..ead79a46 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -528,43 +528,80 @@ router.post( try { const settings = getSettings(); const userRepository = getRepository(User); - const body = req.body as { plexIds: string[] } | undefined; + const { plexIds, profileIds } = req.body as { + plexIds?: string[]; + profileIds?: string[]; + }; + + const skippedItems: { + id: string; + type: 'user' | 'profile'; + reason: string; + }[] = []; + const createdUsers: User[] = []; - // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true }, + select: { id: true, plexToken: true, email: true, plexId: true }, where: { id: 1 }, }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - const plexUsersResponse = await mainPlexTv.getUsers(); - const createdUsers: User[] = []; - for (const rawUser of plexUsersResponse.MediaContainer.User) { - const account = rawUser.$; + if (plexIds && plexIds.length > 0) { + const plexUsersResponse = await mainPlexTv.getUsers(); - if (account.email) { - const user = await userRepository - .createQueryBuilder('user') - .where('user.plexId = :id', { id: account.id }) - .orWhere('user.email = :email', { - email: account.email.toLowerCase(), - }) - .getOne(); + for (const rawUser of plexUsersResponse.MediaContainer.User) { + const account = rawUser.$; - if (user) { - // Update the user's avatar with their Plex thumbnail, in case it changed - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; + if (account.email && plexIds.includes(account.id)) { + // Check for duplicate users more thoroughly + const user = await userRepository + .createQueryBuilder('user') + .where('user.plexId = :id', { id: account.id }) + .orWhere('user.email = :email', { + email: account.email.toLowerCase(), + }) + .orWhere('user.plexUsername = :username', { + username: account.username, + }) + .getOne(); + + if (user) { + // Update the user's avatar with their Plex thumbnail, in case it changed + user.avatar = account.thumb; + user.email = account.email; + user.plexUsername = account.username; + + // In case the user was previously a local account + if (user.userType === UserType.LOCAL) { + user.userType = UserType.PLEX; + user.plexId = parseInt(account.id); + } + + await userRepository.save(user); + skippedItems.push({ + id: account.id, + type: 'user', + reason: 'USER_ALREADY_EXISTS', + }); + } else if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { + // Check for profiles with the same username + const existingProfile = await userRepository.findOne({ + where: { + plexUsername: account.username, + isPlexProfile: true, + }, + }); + + if (existingProfile) { + skippedItems.push({ + id: account.id, + type: 'user', + reason: 'PROFILE_WITH_SAME_NAME_EXISTS', + }); + continue; + } - // In case the user was previously a local account - if (user.userType === UserType.LOCAL) { - user.userType = UserType.PLEX; - user.plexId = parseInt(account.id); - } - await userRepository.save(user); - } else if (!body || body.plexIds.includes(account.id)) { - if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { const newUser = new User({ plexUsername: account.username, email: account.email, @@ -574,6 +611,7 @@ router.post( avatar: account.thumb, userType: UserType.PLEX, }); + await userRepository.save(newUser); createdUsers.push(newUser); } @@ -581,7 +619,64 @@ router.post( } } - return res.status(201).json(User.filterMany(createdUsers)); + if (profileIds && profileIds.length > 0) { + const profiles = await mainPlexTv.getProfiles(); + + for (const profileId of profileIds) { + const profileData = profiles.find((p: any) => p.id === profileId); + + if (profileData) { + const emailPrefix = mainUser.email.split('@')[0]; + const domainPart = mainUser.email.includes('@') + ? mainUser.email.split('@')[1] + : 'plex.local'; + + const safeUsername = (profileData.username || profileData.title) + .replace(/\s+/g, '.') + .replace(/[^a-zA-Z0-9._-]/g, ''); + + // Check for existing user with same username or email + const existingUser = await userRepository.findOne({ + where: [ + { plexUsername: profileData.username || profileData.title }, + { email: `${emailPrefix}+${safeUsername}@${domainPart}` }, + { plexProfileId: profileId }, + ], + }); + + if (existingUser) { + // Skip this profile and add to skipped list + skippedItems.push({ + id: profileId, + type: 'profile', + reason: 'DUPLICATE_USER_EXISTS', + }); + continue; + } + + const profileUser = new User({ + email: `${emailPrefix}+${safeUsername}@${domainPart}`, + plexUsername: profileData.username || profileData.title, + plexId: mainUser.plexId, + plexToken: mainUser.plexToken, + permissions: settings.main.defaultPermissions, + avatar: profileData.thumb, + userType: UserType.PLEX, + plexProfileId: profileId, + isPlexProfile: true, + mainPlexUserId: mainUser.id, + }); + + await userRepository.save(profileUser); + createdUsers.push(profileUser); + } + } + } + + return res.status(201).json({ + data: User.filterMany(createdUsers), + skipped: skippedItems, + }); } catch (e) { next({ status: 500, message: e.message }); } diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 63bd85f7..20f6b922 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -132,7 +132,7 @@ const Login = () => { if (response.data?.status === 'REQUIRES_PIN') { setShowPinEntry(true); setPinError(intl.formatMessage(messages.invalidPin)); - return; + throw new Error('Invalid PIN'); } else { setShowProfileSelector(false); setShowPinEntry(false); @@ -301,7 +301,9 @@ const Login = () => { { + return handleSubmitProfile(pinProfileId, pin); + }} onCancel={() => { setShowPinEntry(false); setPinProfileId(null); diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx index fcf5441b..f68ecec6 100644 --- a/src/components/UserList/PlexImportModal.tsx +++ b/src/components/UserList/PlexImportModal.tsx @@ -3,9 +3,11 @@ 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 type PlexUser from '@server/api/plexapi'; +import type { PlexProfile } from '@server/api/plextv'; import axios from 'axios'; import Image from 'next/image'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -15,15 +17,37 @@ interface PlexImportProps { onComplete?: () => void; } +interface PlexImportData { + users: PlexUser[]; + profiles: PlexProfile[]; +} + const messages = defineMessages('components.UserList', { - importfromplex: 'Import Plex Users', - importfromplexerror: 'Something went wrong while importing Plex users.', - importedfromplex: - '{userCount} Plex {userCount, plural, one {user} other {users}} imported successfully!', + importfromplex: 'Import Plex Users & Profiles', + importfromplexerror: + 'Something went wrong while importing Plex users and profiles.', user: 'User', - nouserstoimport: 'There are no Plex users to import.', + profile: 'Profile', + nouserstoimport: 'There are no Plex users or profiles to import.', newplexsigninenabled: 'The Enable New Plex Sign-In setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.', + possibleDuplicate: 'Possible duplicate', + duplicateUserWarning: + 'This user appears to be a duplicate of an existing user or profile.', + duplicateProfileWarning: + 'This profile appears to be a duplicate of an existing user or profile.', + importSuccess: + '{count, plural, one {# item was} other {# items were}} imported successfully.', + importSuccessUsers: + '{count, plural, one {# user was} other {# users were}} imported successfully.', + importSuccessProfiles: + '{count, plural, one {# profile was} other {# profiles were}} imported successfully.', + importSuccessMixed: + '{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.', + skippedUsersDuplicates: + '{count, plural, one {# user was} other {# users were}} skipped due to duplicates.', + skippedProfilesDuplicates: + '{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.', }); const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { @@ -32,44 +56,135 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { const { addToast } = useToasts(); const [isImporting, setImporting] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); - const { data, error } = useSWR< + const [selectedProfiles, setSelectedProfiles] = useState([]); + const [duplicateMap, setDuplicateMap] = useState<{ + [key: string]: { type: 'user' | 'profile'; duplicateWith: string[] }; + }>({}); + + const { data, error } = useSWR( + '/api/v1/settings/plex/users', { - id: string; - title: string; - username: string; - email: string; - thumb: string; - }[] - >(`/api/v1/settings/plex/users`, { - revalidateOnMount: true, - }); + revalidateOnMount: true, + } + ); + + useEffect(() => { + if (data) { + const duplicates: { + [key: string]: { type: 'user' | 'profile'; duplicateWith: string[] }; + } = {}; + + const usernameMap = new Map(); + + data.users.forEach((user: PlexUser) => { + usernameMap.set(user.username.toLowerCase(), user.id); + }); + + data.profiles.forEach((profile: PlexProfile) => { + const profileName = (profile.username || profile.title).toLowerCase(); + + if (usernameMap.has(profileName)) { + const userId = usernameMap.get(profileName); + + duplicates[`profile-${profile.id}`] = { + type: 'profile', + duplicateWith: [`user-${userId}`], + }; + + duplicates[`user-${userId}`] = { + type: 'user', + duplicateWith: [`profile-${profile.id}`], + }; + } + }); + + setDuplicateMap(duplicates); + } + }, [data]); const importUsers = async () => { setImporting(true); try { - const { data: createdUsers } = await axios.post( + const { data: response } = await axios.post( '/api/v1/user/import-from-plex', - { plexIds: selectedUsers } - ); - - if (!Array.isArray(createdUsers) || createdUsers.length === 0) { - throw new Error('No users were imported from Plex.'); - } - - addToast( - intl.formatMessage(messages.importedfromplex, { - userCount: createdUsers.length, - strong: (msg: React.ReactNode) => {msg}, - }), { - autoDismiss: true, - appearance: 'success', + plexIds: selectedUsers, + profileIds: selectedProfiles, } ); - if (onComplete) { - onComplete(); + if (response.data) { + const importedUsers = response.data.filter( + (item) => !item.isPlexProfile + ).length; + const importedProfiles = response.data.filter( + (item) => item.isPlexProfile + ).length; + + let successMessage; + if (importedUsers > 0 && importedProfiles > 0) { + successMessage = intl.formatMessage(messages.importSuccessMixed, { + userCount: importedUsers, + profileCount: importedProfiles, + }); + } else if (importedUsers > 0) { + successMessage = intl.formatMessage(messages.importSuccessUsers, { + count: importedUsers, + }); + } else if (importedProfiles > 0) { + successMessage = intl.formatMessage(messages.importSuccessProfiles, { + count: importedProfiles, + }); + } else { + successMessage = intl.formatMessage(messages.importSuccess, { + count: response.data.length, + }); + } + + let finalMessage = successMessage; + + if (response.skipped && response.skipped.length > 0) { + const skippedUsers = response.skipped.filter( + (item) => item.type === 'user' + ).length; + const skippedProfiles = response.skipped.filter( + (item) => item.type === 'profile' + ).length; + + let skippedMessage = ''; + if (skippedUsers > 0) { + skippedMessage += intl.formatMessage( + messages.skippedUsersDuplicates, + { + count: skippedUsers, + } + ); + } + + if (skippedProfiles > 0) { + if (skippedMessage) skippedMessage += ' '; + skippedMessage += intl.formatMessage( + messages.skippedProfilesDuplicates, + { + count: skippedProfiles, + } + ); + } + + finalMessage += ` ${skippedMessage}`; + } + + addToast(finalMessage, { + autoDismiss: true, + appearance: 'success', + }); + + if (onComplete) { + onComplete(); + } + } else { + throw new Error('Invalid response format'); } } catch (e) { addToast(intl.formatMessage(messages.importfromplexerror), { @@ -84,24 +199,118 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { const isSelectedUser = (plexId: string): boolean => selectedUsers.includes(plexId); - const isAllUsers = (): boolean => selectedUsers.length === data?.length; + const isSelectedProfile = (plexId: string): boolean => + selectedProfiles.includes(plexId); + + const isDuplicate = (type: 'user' | 'profile', id: string): boolean => { + const key = `${type}-${id}`; + return !!duplicateMap[key]; + }; + + const isDuplicateWithSelected = ( + type: 'user' | 'profile', + id: string + ): boolean => { + const key = `${type}-${id}`; + if (!duplicateMap[key]) return false; + + return duplicateMap[key].duplicateWith.some((dup) => { + if (dup.startsWith('user-')) { + const userId = dup.replace('user-', ''); + return selectedUsers.includes(userId); + } else if (dup.startsWith('profile-')) { + const profileId = dup.replace('profile-', ''); + return selectedProfiles.includes(profileId); + } + return false; + }); + }; + + const hasSelectedDuplicate = ( + type: 'user' | 'profile', + id: string + ): boolean => { + if (type === 'user' && selectedUsers.includes(id)) { + return isDuplicateWithSelected('user', id); + } else if (type === 'profile' && selectedProfiles.includes(id)) { + return isDuplicateWithSelected('profile', id); + } + return false; + }; + + const isAllUsers = (): boolean => + data?.users && data.users.length > 0 + ? selectedUsers.length === data.users.length + : false; + + const isAllProfiles = (): boolean => + data?.profiles && data.profiles.length > 0 + ? selectedProfiles.length === data.profiles.length + : false; const toggleUser = (plexId: string): void => { if (selectedUsers.includes(plexId)) { - setSelectedUsers((users) => users.filter((user) => user !== plexId)); + setSelectedUsers((users: string[]) => + users.filter((user: string) => user !== plexId) + ); } else { - setSelectedUsers((users) => [...users, plexId]); + const willCreateDuplicate = isDuplicateWithSelected('user', plexId); + + if (willCreateDuplicate) { + addToast(intl.formatMessage(messages.duplicateUserWarning), { + autoDismiss: true, + appearance: 'warning', + }); + } + + setSelectedUsers((users: string[]) => [...users, plexId]); + } + }; + + const toggleProfile = (plexId: string): void => { + if (selectedProfiles.includes(plexId)) { + setSelectedProfiles((profiles: string[]) => + profiles.filter((profile: string) => profile !== plexId) + ); + } else { + const willCreateDuplicate = isDuplicateWithSelected('profile', plexId); + + if (willCreateDuplicate) { + addToast(intl.formatMessage(messages.duplicateProfileWarning), { + autoDismiss: true, + appearance: 'warning', + }); + } + + setSelectedProfiles((profiles: string[]) => [...profiles, plexId]); } }; const toggleAllUsers = (): void => { - if (data && selectedUsers.length >= 0 && !isAllUsers()) { - setSelectedUsers(data.map((user) => user.id)); + if (data?.users && data.users.length > 0 && !isAllUsers()) { + setSelectedUsers(data.users.map((user: PlexUser) => user.id)); } else { setSelectedUsers([]); } }; + const toggleAllProfiles = (): void => { + if (data?.profiles && data.profiles.length > 0 && !isAllProfiles()) { + setSelectedProfiles( + data.profiles.map((profile: PlexProfile) => profile.id) + ); + } else { + setSelectedProfiles([]); + } + }; + + const hasImportableContent = + (data?.users && data.users.length > 0) || + (data?.profiles && data.profiles.length > 0); + + const hasSelectedContent = + selectedUsers.length > 0 || selectedProfiles.length > 0; + return ( { onOk={() => { importUsers(); }} - okDisabled={isImporting || !selectedUsers.length} + okDisabled={isImporting || !hasSelectedContent} okText={intl.formatMessage( isImporting ? globalMessages.importing : globalMessages.import )} onCancel={onCancel} > - {data?.length ? ( + {hasImportableContent ? ( <> {settings.currentSettings.newPlexLogin && ( { type="info" /> )} -
-
-
-
- - - - - - - - - {data?.map((user) => ( - - + + ))} + +
- toggleAllUsers()} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { - toggleAllUsers(); - } - }} - className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" - > - - - - - {intl.formatMessage(messages.user)} -
+ + {/* Plex Users Section */} + {data?.users && data.users.length > 0 && ( +
+

Plex Users

+
+
+
+ + + + + + + + + {data.users.map((user) => ( + + + + + ))} + +
toggleUser(user.id)} + aria-checked={isAllUsers()} + onClick={() => toggleAllUsers()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Space') { - toggleUser(user.id); + toggleAllUsers(); } }} className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" @@ -185,7 +363,132 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { + + + + {intl.formatMessage(messages.user)} +
+ toggleUser(user.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleUser(user.id); + } + }} + className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" + > + + + + +
+ +
+
+ {user.username} + {isDuplicate('user', user.id) && ( + + {intl.formatMessage( + messages.possibleDuplicate + )} + + )} +
+ {user.username && + user.username.toLowerCase() !== + user.email && ( +
+ {user.email} +
+ )} +
+
+
+
+
+
+
+ )} + + {/* Plex Profiles Section */} + {data?.profiles && data.profiles.length > 0 && ( +
+

Plex Profiles

+
+
+
+ + + + + + + + {data.profiles.map((profile) => ( + + + - - ))} - -
+ toggleAllProfiles()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleAllProfiles(); + } + }} + className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" + > + - - -
- -
-
- {user.username} -
- {user.username && - user.username.toLowerCase() !== - user.email && ( + +
+ {intl.formatMessage(messages.profile)} +
+ toggleProfile(profile.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleProfile(profile.id); + } + }} + className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" + > + + + + +
+ +
+
+ {profile.title || profile.username} + {isDuplicate('profile', profile.id) && ( + + {intl.formatMessage( + messages.possibleDuplicate + )} + + )} +
+ {profile.protected && (
- {user.email} + (PIN protected)
)} +
- -
+
+
- + )} ) : ( {userCount} {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!", - "components.UserList.importedfromplex": "{userCount} Plex {userCount, plural, one {user} other {users}} imported successfully!", "components.UserList.importfromJellyfin": "Import {mediaServerName} Users", "components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.", "components.UserList.importfrommediaserver": "Import {mediaServerName} Users", - "components.UserList.importfromplex": "Import Plex Users", - "components.UserList.importfromplexerror": "Something went wrong while importing Plex users.", + "components.UserList.importfromplex": "Import Plex Users & Profiles", + "components.UserList.importfromplexerror": "Something went wrong while importing Plex users and profiles.", "components.UserList.localLoginDisabled": "The Enable Local Sign-In setting is currently disabled.", "components.UserList.localuser": "Local User", "components.UserList.mediaServerUser": "{mediaServerName} User", "components.UserList.newJellyfinsigninenabled": "The Enable New {mediaServerName} Sign-In setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.", "components.UserList.newplexsigninenabled": "The Enable New Plex Sign-In setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.", "components.UserList.noJellyfinuserstoimport": "There are no {mediaServerName} users to import.", - "components.UserList.nouserstoimport": "There are no Plex users to import.", + "components.UserList.nouserstoimport": "There are no Plex users or profiles to import.", "components.UserList.owner": "Owner", "components.UserList.password": "Password", "components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.", "components.UserList.plexuser": "Plex User", + "components.UserList.possibleDuplicate": "Possible duplicate", + "components.UserList.profile": "Profile", "components.UserList.role": "Role", + "components.UserList.skippedProfilesDuplicates": "{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.", + "components.UserList.skippedUsersDuplicates": "{count, plural, one {# user was} other {# users were}} skipped due to duplicates.", "components.UserList.sortCreated": "Join Date", "components.UserList.sortDisplayName": "Display Name", "components.UserList.sortRequests": "Request Count",