diff --git a/seerr-api.yml b/seerr-api.yml index ae1fdc254f..0bc30f1880 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4413,18 +4413,23 @@ paths: type: string responses: '201': - description: A list of the newly created users + description: The newly created users and a count of existing users that were refreshed content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/User' + type: object + properties: + createdUsers: + type: array + items: + $ref: '#/components/schemas/User' + refreshedUsers: + type: number /user/import-from-jellyfin: post: summary: Import all users from Jellyfin description: | - Fetches and imports users from the Jellyfin server. + Fetches and imports users from the Jellyfin/Emby server. If a list of Jellyfin user IDs is provided in the request body, only the specified users will be created as new accounts. Otherwise, all users will be imported. Requires the `MANAGE_USERS` permission. tags: @@ -4442,13 +4447,18 @@ paths: type: string responses: '201': - description: A list of the newly created users + description: The newly created users and a count of existing users that were refreshed content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/User' + type: object + properties: + createdUsers: + type: array + items: + $ref: '#/components/schemas/User' + refreshedUsers: + type: number /user/registerPushSubscription: post: summary: Register a web push /user/registerPushSubscription diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index c61522a516..69a7b41d1d 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -44,7 +44,7 @@ export async function checkAvatarChanged( let headResponse; try { - headResponse = await axios.head(jellyfinAvatarUrl); + headResponse = await axios.head(jellyfinAvatarUrl, { timeout: 5000 }); if (headResponse.status !== 200) { return { changed: false }; } diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 44d72676c9..e9e7fd6baa 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -31,6 +31,7 @@ import { appDataPath } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; import { dnsCache } from '@server/utils/dnsCache'; import { getHostname } from '@server/utils/getHostname'; +import { normalizeJellyfinGuid } from '@server/utils/jellyfin'; import type { DnsEntries, DnsStats } from 'dns-caching'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; @@ -472,14 +473,37 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const resp = await jellyfinClient.getUsers(); - const users = resp.users.map((user) => ({ - username: user.Name, - id: user.Id, - thumb: `/avatarproxy/${user.Id}`, - email: user.Name, - })); - return res.status(200).json(users); + const jellyfinUserIds = resp.users + .map((user) => normalizeJellyfinGuid(user.Id)) + .filter((id): id is string => !!id); + + const existingUsers = jellyfinUserIds.length + ? await userRepository + .createQueryBuilder('user') + .select(['user.jellyfinUserId']) + .where( + "LOWER(REPLACE(user.jellyfinUserId, '-', '')) IN (:...jellyfinUserIds)", + { jellyfinUserIds } + ) + .getMany() + : []; + const existingUserIds = new Set( + existingUsers + .map((user) => normalizeJellyfinGuid(user.jellyfinUserId)) + .filter((id): id is string => !!id) + ); + + const unimportedUsers = resp.users + .filter((user) => !existingUserIds.has(normalizeJellyfinGuid(user.Id))) + .map((user) => ({ + username: user.Name, + id: user.Id, + thumb: `/avatarproxy/${user.Id}`, + email: user.Name, + })); + + return res.status(200).json(unimportedUsers); }); settingsRoutes.get('/jellyfin/sync', (_req, res) => { diff --git a/server/routes/user/index.test.ts b/server/routes/user/index.test.ts new file mode 100644 index 0000000000..4bcb304ddf --- /dev/null +++ b/server/routes/user/index.test.ts @@ -0,0 +1,455 @@ +import type { JellyfinUserResponse } from '@server/api/jellyfin'; +import JellyfinAPI from '@server/api/jellyfin'; +import PlexTvAPI from '@server/api/plextv'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; +import authRoutes from '@server/routes/auth'; +import { setupTestDb } from '@server/test/db'; +import type { Express } from 'express'; +import express from 'express'; +import session from 'express-session'; +import assert from 'node:assert/strict'; +import { before, beforeEach, describe, it, mock } from 'node:test'; +import request from 'supertest'; +import userRoutes from '.'; + +function jellyfinUser( + overrides: Partial +): JellyfinUserResponse { + return { + Name: 'user', + ServerId: 'server-1', + ServerName: 'Test Server', + Id: 'jf-user-id', + Configuration: { GroupedFolders: [] }, + Policy: { IsAdministrator: false }, + ...overrides, + }; +} + +interface PlexUserFixture { + id: string; + title: string; + username: string; + email: string; + thumb: string; +} + +function plexUser(overrides: Partial): { + $: PlexUserFixture; + Server: unknown[]; +} { + return { + $: { + id: '1', + title: 'user', + username: 'user', + email: 'user@example.com', + thumb: '/plex-thumb', + ...overrides, + }, + Server: [], + }; +} + +const getUsersMock = mock.method( + JellyfinAPI.prototype, + 'getUsers', + async () => ({ + users: [] as JellyfinUserResponse[], + }) +); + +const getPlexUsersMock = mock.method( + PlexTvAPI.prototype, + 'getUsers', + async () => ({ + MediaContainer: { User: [] as ReturnType[] }, + }) +); + +const checkUserAccessMock = mock.method( + PlexTvAPI.prototype, + 'checkUserAccess', + async () => true +); + +let app: Express; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + }) + ); + app.use(checkUser); + app.use('/auth', authRoutes); + app.use('/user', isAuthenticated(), userRoutes); + app.use( + ( + err: { status?: number; message?: string }, + _req: express.Request, + res: express.Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction + ) => { + res + .status(err.status ?? 500) + .json({ status: err.status ?? 500, message: err.message }); + } + ); + return app; +} + +before(async () => { + app = createApp(); +}); + +setupTestDb(); + +function configureJellyfin() { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.JELLYFIN; + settings.jellyfin.ip = 'localhost'; + settings.jellyfin.port = 8096; + settings.jellyfin.useSsl = false; + settings.jellyfin.urlBase = ''; +} + +async function adminAgent() { + const settings = getSettings(); + settings.main.localLogin = true; + + const agent = request.agent(app); + const res = await agent + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'test1234' }); + + assert.strictEqual(res.status, 200); + return agent; +} + +describe('POST /user/import-from-jellyfin', () => { + beforeEach(() => { + getUsersMock.mock.resetCalls(); + getUsersMock.mock.mockImplementation(async () => ({ users: [] })); + configureJellyfin(); + }); + + const NEW_USER_ID = 'aaaa1111aaaa1111aaaa1111aaaa1111'; + const UNCHECKED_USER_ID = 'bbbb2222bbbb2222bbbb2222bbbb2222'; + const EXISTING_USER_ID = 'cccc3333cccc3333cccc3333cccc3333'; + const EMBY_USER_ID = 'dddd4444dddd4444dddd4444dddd4444'; + + it('creates a new user when checked and not already present locally', async () => { + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: NEW_USER_ID, Name: 'newuser' })], + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: [NEW_USER_ID] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 1); + assert.strictEqual(res.body.refreshedUsers, 0); + + const newUser = await getRepository(User).findOneOrFail({ + where: { jellyfinUserId: NEW_USER_ID }, + }); + assert.strictEqual(newUser.jellyfinUsername, 'newuser'); + assert.strictEqual(newUser.userType, UserType.JELLYFIN); + }); + + it('creates a new user when the checked id is provided with dashes (#2338)', async () => { + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: NEW_USER_ID, Name: 'dasheduser' })], + })); + + const dashedNewUserId = `${NEW_USER_ID.slice(0, 8)}-${NEW_USER_ID.slice(8, 12)}-${NEW_USER_ID.slice(12, 16)}-${NEW_USER_ID.slice(16, 20)}-${NEW_USER_ID.slice(20)}`; + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: [dashedNewUserId] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 1); + + const newUser = await getRepository(User).findOneOrFail({ + where: { jellyfinUserId: NEW_USER_ID }, + }); + assert.strictEqual(newUser.jellyfinUsername, 'dasheduser'); + }); + + it('does not create a user present on the server but not checked', async () => { + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: UNCHECKED_USER_ID, Name: 'unchecked' })], + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: [] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 0); + + const user = await getRepository(User).findOne({ + where: { jellyfinUserId: UNCHECKED_USER_ID }, + }); + assert.strictEqual(user, null); + }); + + it('refreshes an existing local user regardless of checkbox selection, without touching unrelated columns', async () => { + const userRepo = getRepository(User); + const existingUser = new User({ + email: 'existing@seerr.dev', + jellyfinUsername: 'oldname', + jellyfinUserId: EXISTING_USER_ID, + permissions: Permission.ADMIN, + avatar: `/avatarproxy/${EXISTING_USER_ID}?v=0`, + avatarVersion: 'v3', + userType: UserType.JELLYFIN, + }); + await userRepo.save(existingUser); + + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: EXISTING_USER_ID, Name: 'newname' })], + })); + + const agent = await adminAgent(); + // Not checked in the UI - refresh should still happen, matching Plex's + // "refresh everything matched, only create what's checked" behavior. + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: [] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 0); + assert.strictEqual(res.body.refreshedUsers, 1); + + const updatedUser = await userRepo.findOneOrFail({ + where: { jellyfinUserId: EXISTING_USER_ID }, + }); + assert.strictEqual(updatedUser.jellyfinUsername, 'newname'); + assert.strictEqual( + updatedUser.avatar, + `/avatarproxy/${EXISTING_USER_ID}?v=v3` + ); + assert.strictEqual(updatedUser.permissions, Permission.ADMIN); + }); + + it('skips live users with a malformed (non-GUID) id without error', async () => { + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: 'not-a-guid', Name: 'malformed' })], + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: ['not-a-guid'] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 0); + assert.strictEqual(res.body.refreshedUsers, 0); + }); + + it('creates Emby-typed users when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: EMBY_USER_ID, Name: 'embyuser' })], + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: [EMBY_USER_ID] }); + + assert.strictEqual(res.status, 201); + + const newUser = await getRepository(User).findOneOrFail({ + where: { jellyfinUserId: EMBY_USER_ID }, + }); + assert.strictEqual(newUser.userType, UserType.EMBY); + }); + + it('creates users when the request body is omitted entirely', async () => { + getUsersMock.mock.mockImplementation(async () => ({ + users: [jellyfinUser({ Id: NEW_USER_ID, Name: 'nobodyuser' })], + })); + + const agent = await adminAgent(); + const res = await agent.post('/user/import-from-jellyfin'); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 1); + + const newUser = await getRepository(User).findOneOrFail({ + where: { jellyfinUserId: NEW_USER_ID }, + }); + assert.strictEqual(newUser.jellyfinUsername, 'nobodyuser'); + }); + + it('returns the createdUsers/refreshedUsers response shape', async () => { + getUsersMock.mock.mockImplementation(async () => ({ users: [] })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-jellyfin') + .send({ jellyfinUserIds: [] }); + + assert.strictEqual(res.status, 201); + assert.deepStrictEqual(Object.keys(res.body).sort(), [ + 'createdUsers', + 'refreshedUsers', + ]); + }); +}); + +describe('POST /user/import-from-plex', () => { + beforeEach(() => { + getPlexUsersMock.mock.resetCalls(); + getPlexUsersMock.mock.mockImplementation(async () => ({ + MediaContainer: { User: [] }, + })); + checkUserAccessMock.mock.resetCalls(); + checkUserAccessMock.mock.mockImplementation(async () => true); + }); + + it('creates a new user with a lowercased email regardless of source casing', async () => { + getPlexUsersMock.mock.mockImplementation(async () => ({ + MediaContainer: { + User: [ + plexUser({ + id: '9001', + email: 'NewUser@Example.com', + username: 'newuser', + }), + ], + }, + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-plex') + .send({ plexIds: ['9001'] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 1); + + const newUser = await getRepository(User).findOneOrFail({ + where: { plexId: 9001 }, + }); + assert.strictEqual(newUser.email, 'newuser@example.com'); + }); + + it('matches an existing user by plexId even though the live id is a numeric string', async () => { + const userRepo = getRepository(User); + const existingUser = new User({ + email: 'old@example.com', + plexUsername: 'oldname', + plexId: 9002, + plexToken: '', + permissions: 0, + avatar: '/old-thumb', + userType: UserType.PLEX, + }); + await userRepo.save(existingUser); + + getPlexUsersMock.mock.mockImplementation(async () => ({ + MediaContainer: { + User: [ + plexUser({ + id: '9002', + email: 'renamed@example.com', + username: 'renamedname', + }), + ], + }, + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-plex') + .send({ plexIds: [] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.refreshedUsers, 1); + + const updatedUser = await userRepo.findOneOrFail({ + where: { id: existingUser.id }, + }); + assert.strictEqual(updatedUser.email, 'renamed@example.com'); + assert.strictEqual(updatedUser.plexUsername, 'renamedname'); + }); + + it('upgrades a LOCAL account to PLEX and keeps email casing normalized when matched by email', async () => { + const userRepo = getRepository(User); + const existingUser = new User({ + email: 'localuser@example.com', + permissions: 0, + avatar: '/local-avatar', + userType: UserType.LOCAL, + }); + await userRepo.save(existingUser); + + getPlexUsersMock.mock.mockImplementation(async () => ({ + MediaContainer: { + User: [ + plexUser({ + id: '9003', + email: 'LocalUser@Example.com', + username: 'plexname', + }), + ], + }, + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-plex') + .send({ plexIds: [] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.refreshedUsers, 1); + + const updatedUser = await userRepo.findOneOrFail({ + where: { id: existingUser.id }, + }); + assert.strictEqual(updatedUser.userType, UserType.PLEX); + assert.strictEqual(updatedUser.plexId, 9003); + assert.strictEqual(updatedUser.email, 'localuser@example.com'); + }); + + it('does not create a user present on the server but not checked', async () => { + getPlexUsersMock.mock.mockImplementation(async () => ({ + MediaContainer: { + User: [plexUser({ id: '9004', email: 'unchecked@example.com' })], + }, + })); + + const agent = await adminAgent(); + const res = await agent + .post('/user/import-from-plex') + .send({ plexIds: [] }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdUsers.length, 0); + + const user = await getRepository(User).findOne({ + where: { plexId: 9004 }, + }); + assert.strictEqual(user, null); + }); +}); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 7d6234d9bb..ae8664633e 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -21,6 +21,7 @@ import { Permission, hasPermission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { checkAvatarChanged } from '@server/routes/avatarproxy'; import { getHostname } from '@server/utils/getHostname'; import { normalizeJellyfinGuid } from '@server/utils/jellyfin'; import { isOwnProfileOrAdmin } from '@server/utils/profileMiddleware'; @@ -677,7 +678,7 @@ router.post( if (account.email) { const user = await userRepository .createQueryBuilder('user') - .where('user.plexId = :id', { id: account.id }) + .where('user.plexId = :id', { id: parseInt(account.id) }) .orWhere('user.email = :email', { email: account.email.toLowerCase(), }) @@ -731,7 +732,7 @@ router.post( try { const settings = getSettings(); const userRepository = getRepository(User); - const body = req.body as { jellyfinUserIds: string[] }; + const body = req.body as { jellyfinUserIds: string[] } | undefined; // taken from auth.ts const admin = await userRepository.findOneOrFail({ @@ -748,10 +749,9 @@ router.post( ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); - //const jellyfinUsersResponse = await jellyfinClient.getUsers(); const createdUsers: User[] = []; + let refreshedUsers = 0; - jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsersById = new Map( @@ -761,29 +761,51 @@ router.post( ]) ); - for (const rawJellyfinUserId of body.jellyfinUserIds) { - const jellyfinUserId = normalizeJellyfinGuid(rawJellyfinUserId); + for (const [jellyfinUserId, jellyfinUser] of jellyfinUsersById) { if (!jellyfinUserId) { continue; } - const jellyfinUser = jellyfinUsersById.get(jellyfinUserId); - - const user = await userRepository.findOne({ - select: ['id', 'jellyfinUserId'], - where: { jellyfinUserId: jellyfinUserId }, - }); + const user = await userRepository + .createQueryBuilder('user') + .select([ + 'user.id', + 'user.jellyfinUserId', + 'user.avatarVersion', + 'user.avatarETag', + 'user.email', + ]) + .where( + "LOWER(REPLACE(user.jellyfinUserId, '-', '')) = :jellyfinUserId", + { + jellyfinUserId, + } + ) + .getOne(); - if (!user) { + if (user) { + await checkAvatarChanged(user); + await userRepository.update(user.id, { + jellyfinUsername: jellyfinUser.Name, + avatar: `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`, + }); + refreshedUsers += 1; + } else if ( + !body || + !body.jellyfinUserIds || + body.jellyfinUserIds.some( + (id) => normalizeJellyfinGuid(id) === jellyfinUserId + ) + ) { const newUser = new User({ - jellyfinUsername: jellyfinUser?.Name, - jellyfinUserId: jellyfinUser?.Id, + jellyfinUsername: jellyfinUser.Name, + jellyfinUserId: jellyfinUserId, jellyfinDeviceId: Buffer.from( - `BOT_seerr_${jellyfinUser?.Name ?? ''}` + `BOT_seerr_${jellyfinUser.Name ?? ''}` ).toString('base64'), - email: jellyfinUser?.Name, + email: jellyfinUser.Name, permissions: settings.main.defaultPermissions, - avatar: `/avatarproxy/${jellyfinUser?.Id}`, + avatar: `/avatarproxy/${jellyfinUserId}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN @@ -794,7 +816,10 @@ router.post( createdUsers.push(newUser); } } - return res.status(201).json(User.filterMany(createdUsers)); + return res.status(201).json({ + createdUsers: User.filterMany(createdUsers), + refreshedUsers, + }); } catch (e) { next({ status: 500, message: e.message }); } diff --git a/src/components/UserList/JellyfinImportModal.tsx b/src/components/UserList/JellyfinImportModal.tsx index f85756b318..d29282420d 100644 --- a/src/components/UserList/JellyfinImportModal.tsx +++ b/src/components/UserList/JellyfinImportModal.tsx @@ -6,7 +6,6 @@ import useToasts from '@app/hooks/useToasts'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { MediaServerType } from '@server/constants/server'; -import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import axios from 'axios'; import { useState } from 'react'; import { useIntl } from 'react-intl'; @@ -15,13 +14,23 @@ import useSWR from 'swr'; interface JellyfinImportProps { onCancel?: () => void; onComplete?: () => void; - children?: React.ReactNode; +} + +interface JellyfinImportResponse { + createdUsers: unknown[]; + refreshedUsers: number; } const messages = defineMessages('components.UserList', { importfromJellyfin: 'Import {mediaServerName} Users', importfromJellyfinerror: 'Something went wrong while importing {mediaServerName} users.', + importfromJellyfinnochanges: + '{mediaServerName} import completed. No users were created or refreshed.', + importfromJellyfinsynced: + '{mediaServerName} users synced successfully. Existing users were refreshed.', + syncnoticeJellyfin: + 'You can still click Sync to refresh existing {mediaServerName} users, such as updated avatar or username details.', importedfromJellyfin: '{userCount} {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!', importedUsersNoPassword: @@ -32,11 +41,7 @@ const messages = defineMessages('components.UserList', { '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.', }); -const JellyfinImportModal: React.FC = ({ - onCancel, - onComplete, - children, -}) => { +const JellyfinImportModal = ({ onCancel, onComplete }: JellyfinImportProps) => { const intl = useIntl(); const settings = useSettings(); const { addToast } = useToasts(); @@ -54,51 +59,90 @@ const JellyfinImportModal: React.FC = ({ revalidateOnMount: true, }); - const { data: existingUsers } = useSWR( - `/api/v1/user?take=${children}` - ); + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? 'Emby' + : 'Jellyfin'; + + const isSyncOnly = !!data && data.length === 0; const importUsers = async () => { setImporting(true); try { - const { data: createdUsers } = await axios.post( + const { data: importResponse } = await axios.post( '/api/v1/user/import-from-jellyfin', { jellyfinUserIds: selectedUsers } ); - if (!createdUsers.length) { - throw new Error('No users were imported from Jellyfin.'); - } + const { createdUsers, refreshedUsers } = importResponse; - addToast( - intl.formatMessage(messages.importedfromJellyfin, { - userCount: createdUsers.length, - strong: (msg: React.ReactNode) => {msg}, - mediaServerName: - settings.currentSettings.mediaServerType === MediaServerType.EMBY - ? 'Emby' - : 'Jellyfin', - }), - { - autoDismiss: true, - appearance: 'success', + if (isSyncOnly) { + if (refreshedUsers > 0) { + addToast( + intl.formatMessage(messages.importfromJellyfinsynced, { + mediaServerName, + }), + { + autoDismiss: true, + appearance: 'success', + } + ); + } else { + addToast( + intl.formatMessage(messages.importfromJellyfinnochanges, { + mediaServerName, + }), + { + autoDismiss: true, + appearance: 'info', + } + ); } - ); + } else if (createdUsers.length > 0) { + addToast( + intl.formatMessage(messages.importedfromJellyfin, { + userCount: createdUsers.length, + strong: (msg: React.ReactNode) => {msg}, + mediaServerName, + }), + { + autoDismiss: true, + appearance: 'success', + } + ); - addToast( - intl.formatMessage(messages.importedUsersNoPassword, { - applicationTitle: settings.currentSettings.applicationTitle, - mediaServerName: - settings.currentSettings.mediaServerType === MediaServerType.EMBY - ? 'Emby' - : 'Jellyfin', - }), - { - autoDismiss: false, - appearance: 'warning', - } - ); + addToast( + intl.formatMessage(messages.importedUsersNoPassword, { + applicationTitle: settings.currentSettings.applicationTitle, + mediaServerName, + }), + { + autoDismiss: false, + appearance: 'warning', + } + ); + } else if (refreshedUsers > 0) { + addToast( + intl.formatMessage(messages.importfromJellyfinsynced, { + mediaServerName, + }), + { + autoDismiss: true, + appearance: 'success', + } + ); + } else { + addToast( + intl.formatMessage(messages.importfromJellyfinnochanges, { + mediaServerName, + }), + { + autoDismiss: true, + appearance: 'info', + } + ); + } if (onComplete) { onComplete(); @@ -106,10 +150,7 @@ const JellyfinImportModal: React.FC = ({ } catch { addToast( intl.formatMessage(messages.importfromJellyfinerror, { - mediaServerName: - settings.currentSettings.mediaServerType === MediaServerType.EMBY - ? 'Emby' - : 'Jellyfin', + mediaServerName, }), { autoDismiss: true, @@ -146,17 +187,22 @@ const JellyfinImportModal: React.FC = ({ { importUsers(); }} - okDisabled={isImporting || !selectedUsers.length} + okDisabled={ + isImporting || !data || (data.length > 0 && !selectedUsers.length) + } okText={intl.formatMessage( - isImporting ? globalMessages.importing : globalMessages.import + isImporting + ? isSyncOnly + ? globalMessages.syncing + : globalMessages.importing + : isSyncOnly + ? globalMessages.sync + : globalMessages.import )} onCancel={onCancel} > @@ -165,11 +211,7 @@ const JellyfinImportModal: React.FC = ({ {settings.currentSettings.newPlexLogin && ( ( {msg} ), @@ -217,72 +259,58 @@ const JellyfinImportModal: React.FC = ({ - {data - ?.filter( - (user) => - !existingUsers?.results.some( - (u) => u.jellyfinUserId === user.id - ) - ) - .map((user) => ( - - + {data?.map((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" + > +