diff --git a/src/components/ConnectionRequired.tsx b/src/components/ConnectionRequired.tsx index 9c5f402b0a..59e680e219 100644 --- a/src/components/ConnectionRequired.tsx +++ b/src/components/ConnectionRequired.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useEffect, useState } from 'react'; +import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import type { ConnectResponse } from 'jellyfin-apiclient'; @@ -35,116 +35,119 @@ const ConnectionRequired: FunctionComponent = ({ const [ isLoading, setIsLoading ] = useState(true); - useEffect(() => { - const bounce = async (connectionResponse: ConnectResponse) => { - switch (connectionResponse.State) { - case ConnectionState.SignedIn: - // Already logged in, bounce to the home page - console.debug('[ConnectionRequired] already logged in, redirecting to home'); - navigate(BounceRoutes.Home); - return; - case ConnectionState.ServerSignIn: - // Bounce to the login page - if (location.pathname === BounceRoutes.Login) { - setIsLoading(false); - } else { - console.debug('[ConnectionRequired] not logged in, redirecting to login page'); - navigate(`${BounceRoutes.Login}?serverid=${connectionResponse.ApiClient.serverId()}`); - } - return; - case ConnectionState.ServerSelection: - // Bounce to select server page - console.debug('[ConnectionRequired] redirecting to select server page'); - navigate(BounceRoutes.SelectServer); - return; - case ConnectionState.ServerUpdateNeeded: - // Show update needed message and bounce to select server page - try { - await alert({ - text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'), - html: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin') - }); - } catch (ex) { - console.warn('[ConnectionRequired] failed to show alert', ex); - } - console.debug('[ConnectionRequired] server update required, redirecting to select server page'); - navigate(BounceRoutes.SelectServer); - return; - } - - console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State); - }; - - const validateConnection = async () => { - // Check connection status on initial page load - const firstConnection = appRouter.firstConnectionResult; - appRouter.firstConnectionResult = null; - - if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) { - if (firstConnection.State === ConnectionState.ServerSignIn) { - // Verify the wizard is complete - try { - const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`); - if (!infoResponse.ok) { - throw new Error('Public system info request failed'); - } - const systemInfo = await infoResponse.json(); - if (!systemInfo?.StartupWizardCompleted) { - // Update the current ApiClient - // TODO: Is there a better place to handle this? - ServerConnections.setLocalApiClient(firstConnection.ApiClient); - // Bounce to the wizard - console.info('[ConnectionRequired] startup wizard is not complete, redirecting there'); - navigate(BounceRoutes.StartWizard); - return; - } - } catch (ex) { - console.error('[ConnectionRequired] checking wizard status failed', ex); - return; - } + const bounce = useCallback(async (connectionResponse: ConnectResponse) => { + switch (connectionResponse.State) { + case ConnectionState.SignedIn: + // Already logged in, bounce to the home page + console.debug('[ConnectionRequired] already logged in, redirecting to home'); + navigate(BounceRoutes.Home); + return; + case ConnectionState.ServerSignIn: + // Bounce to the login page + if (location.pathname === BounceRoutes.Login) { + setIsLoading(false); + } else { + console.debug('[ConnectionRequired] not logged in, redirecting to login page'); + navigate(`${BounceRoutes.Login}?serverid=${connectionResponse.ApiClient.serverId()}`); } + return; + case ConnectionState.ServerSelection: + // Bounce to select server page + console.debug('[ConnectionRequired] redirecting to select server page'); + navigate(BounceRoutes.SelectServer); + return; + case ConnectionState.ServerUpdateNeeded: + // Show update needed message and bounce to select server page + try { + await alert({ + text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'), + html: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin') + }); + } catch (ex) { + console.warn('[ConnectionRequired] failed to show alert', ex); + } + console.debug('[ConnectionRequired] server update required, redirecting to select server page'); + navigate(BounceRoutes.SelectServer); + return; + } - // Bounce to the correct page in the login flow - bounce(firstConnection); + console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State); + }, [location.pathname, navigate]); + + const handleIncompleteWizard = useCallback(async (firstConnection: ConnectResponse) => { + if (firstConnection.State === ConnectionState.ServerSignIn) { + // Verify the wizard is complete + try { + const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`); + if (!infoResponse.ok) { + throw new Error('Public system info request failed'); + } + const systemInfo = await infoResponse.json(); + if (!systemInfo?.StartupWizardCompleted) { + // Update the current ApiClient + // TODO: Is there a better place to handle this? + ServerConnections.setLocalApiClient(firstConnection.ApiClient); + // Bounce to the wizard + console.info('[ConnectionRequired] startup wizard is not complete, redirecting there'); + navigate(BounceRoutes.StartWizard); + return; + } + } catch (ex) { + console.error('[ConnectionRequired] checking wizard status failed', ex); return; } + } - // TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route. - // This case will need to be handled elsewhere before appRouter can be killed. + // Bounce to the correct page in the login flow + bounce(firstConnection); + }, [bounce, navigate]); - const client = ServerConnections.currentApiClient(); + const validateUserAccess = useCallback(async () => { + const client = ServerConnections.currentApiClient(); - // If this is a user route, ensure a user is logged in - if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) { - try { - console.warn('[ConnectionRequired] unauthenticated user attempted to access user route'); + // If this is a user route, ensure a user is logged in + if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) { + try { + console.warn('[ConnectionRequired] unauthenticated user attempted to access user route'); + bounce(await ServerConnections.connect()); + } catch (ex) { + console.warn('[ConnectionRequired] error bouncing from user route', ex); + } + return; + } + + // If this is an admin route, ensure the user has access + if (isAdminRequired) { + try { + const user = await client?.getCurrentUser(); + if (!user?.Policy?.IsAdministrator) { + console.warn('[ConnectionRequired] normal user attempted to access admin route'); bounce(await ServerConnections.connect()); - } catch (ex) { - console.warn('[ConnectionRequired] error bouncing from user route', ex); - } - return; - } - - // If this is an admin route, ensure the user has access - if (isAdminRequired) { - try { - const user = await client?.getCurrentUser(); - if (!user?.Policy?.IsAdministrator) { - console.warn('[ConnectionRequired] normal user attempted to access admin route'); - bounce(await ServerConnections.connect()); - return; - } - } catch (ex) { - console.warn('[ConnectionRequired] error bouncing from admin route', ex); return; } + } catch (ex) { + console.warn('[ConnectionRequired] error bouncing from admin route', ex); + return; } + } - setIsLoading(false); - }; + setIsLoading(false); + }, [bounce, isAdminRequired, isUserRequired]); - validateConnection(); - }, [ isAdminRequired, isUserRequired, location.pathname, navigate ]); + useEffect(() => { + // TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route. + // This case will need to be handled elsewhere before appRouter can be killed. + + // Check connection status on initial page load + const firstConnection = appRouter.firstConnectionResult; + appRouter.firstConnectionResult = null; + + if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) { + handleIncompleteWizard(firstConnection); + } else { + validateUserAccess(); + } + }, [handleIncompleteWizard, validateUserAccess]); if (isLoading) { return ; diff --git a/src/components/ServerContentPage.tsx b/src/components/ServerContentPage.tsx index 7473c856e8..5b86d66072 100644 --- a/src/components/ServerContentPage.tsx +++ b/src/components/ServerContentPage.tsx @@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom'; import ServerConnections from './ServerConnections'; import viewManager from './viewManager/viewManager'; import globalize from '../scripts/globalize'; +import type { RestoreViewFailResponse } from '../types/viewManager'; interface ServerContentPageProps { view: string @@ -29,7 +30,7 @@ const ServerContentPage: FunctionComponent = ({ view }) }; viewManager.tryRestoreView(viewOptions) - .catch(async (result?: any) => { + .catch(async (result?: RestoreViewFailResponse) => { if (!result || !result.cancelled) { const apiClient = ServerConnections.currentApiClient(); @@ -46,12 +47,13 @@ const ServerContentPage: FunctionComponent = ({ view }) }; loadPage(); - }, [ + }, + // location.state is NOT included as a dependency here since dialogs will update state while the current view stays the same + // eslint-disable-next-line react-hooks/exhaustive-deps + [ view, location.pathname, location.search - // location.state is NOT included as a dependency here since dialogs will update state while the current view - // stays the same ]); return <>; diff --git a/src/components/common/ViewItemsContainer.tsx b/src/components/common/ViewItemsContainer.tsx index 086d160137..d4f2937513 100644 --- a/src/components/common/ViewItemsContainer.tsx +++ b/src/components/common/ViewItemsContainer.tsx @@ -1,4 +1,4 @@ -import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; +import { type BaseItemDtoQueryResult, ItemFields, ItemFilter } from '@jellyfin/sdk/lib/generated-client'; import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import loading from '../loading/loading'; @@ -33,6 +33,41 @@ const getDefaultSortBy = () => { return 'SortName'; }; +const getFields = (viewQuerySettings: ViewQuerySettings) => { + const fields: ItemFields[] = [ + ItemFields.BasicSyncInfo, + ItemFields.MediaSourceCount + ]; + + if (viewQuerySettings.imageType === 'primary') { + fields.push(ItemFields.PrimaryImageAspectRatio); + } + + return fields.join(','); +}; + +const getFilters = (viewQuerySettings: ViewQuerySettings) => { + const filters: ItemFilter[] = []; + + if (viewQuerySettings.IsPlayed) { + filters.push(ItemFilter.IsPlayed); + } + + if (viewQuerySettings.IsUnplayed) { + filters.push(ItemFilter.IsUnplayed); + } + + if (viewQuerySettings.IsFavorite) { + filters.push(ItemFilter.IsFavorite); + } + + if (viewQuerySettings.IsResumable) { + filters.push(ItemFilter.IsResumable); + } + + return filters; +}; + const getVisibleViewSettings = () => { return [ 'showTitle', @@ -228,33 +263,7 @@ const ViewItemsContainer: FC = ({ }, [getCardOptions, getContext, itemsResult.Items, getNoItemsMessage, viewQuerySettings.imageType]); const getQuery = useCallback(() => { - let fields = 'BasicSyncInfo,MediaSourceCount'; - - if (viewQuerySettings.imageType === 'primary') { - fields += ',PrimaryImageAspectRatio'; - } - - if (viewQuerySettings.showYear) { - fields += ',ProductionYear'; - } - - const queryFilters: string[] = []; - - if (viewQuerySettings.IsPlayed) { - queryFilters.push('IsPlayed'); - } - - if (viewQuerySettings.IsUnplayed) { - queryFilters.push('IsUnplayed'); - } - - if (viewQuerySettings.IsFavorite) { - queryFilters.push('IsFavorite'); - } - - if (viewQuerySettings.IsResumable) { - queryFilters.push('IsResumable'); - } + const queryFilters = getFilters(viewQuerySettings); let queryIsHD; @@ -271,7 +280,7 @@ const ViewItemsContainer: FC = ({ SortOrder: viewQuerySettings.SortOrder, IncludeItemTypes: getItemTypes().join(','), Recursive: true, - Fields: fields, + Fields: getFields(viewQuerySettings), ImageTypeLimit: 1, EnableImageTypes: 'Primary,Backdrop,Banner,Thumb,Disc,Logo', Limit: userSettings.libraryPageSize(undefined) || undefined, @@ -293,28 +302,7 @@ const ViewItemsContainer: FC = ({ ParentId: topParentId }; }, [ - viewQuerySettings.imageType, - viewQuerySettings.showYear, - viewQuerySettings.IsPlayed, - viewQuerySettings.IsUnplayed, - viewQuerySettings.IsFavorite, - viewQuerySettings.IsResumable, - viewQuerySettings.IsHD, - viewQuerySettings.IsSD, - viewQuerySettings.SortBy, - viewQuerySettings.SortOrder, - viewQuerySettings.VideoTypes, - viewQuerySettings.GenreIds, - viewQuerySettings.Is4K, - viewQuerySettings.Is3D, - viewQuerySettings.HasSubtitles, - viewQuerySettings.HasTrailer, - viewQuerySettings.HasSpecialFeature, - viewQuerySettings.HasThemeSong, - viewQuerySettings.HasThemeVideo, - viewQuerySettings.StartIndex, - viewQuerySettings.NameLessThan, - viewQuerySettings.NameStartsWith, + viewQuerySettings, getItemTypes, getBasekey, topParentId diff --git a/src/components/dashboard/users/UserPasswordForm.tsx b/src/components/dashboard/users/UserPasswordForm.tsx index 3d3daa2993..8cfb47b5b1 100644 --- a/src/components/dashboard/users/UserPasswordForm.tsx +++ b/src/components/dashboard/users/UserPasswordForm.tsx @@ -1,4 +1,3 @@ -import type { UserDto } from '@jellyfin/sdk/lib/generated-client'; import React, { FunctionComponent, useCallback, useEffect, useRef } from 'react'; import Dashboard from '../../../utils/dashboard'; import globalize from '../../../scripts/globalize'; @@ -17,7 +16,7 @@ type IProps = { const UserPasswordForm: FunctionComponent = ({ userId }: IProps) => { const element = useRef(null); - const loadUser = useCallback(() => { + const loadUser = useCallback(async () => { const page = element.current; if (!page) { @@ -25,61 +24,48 @@ const UserPasswordForm: FunctionComponent = ({ userId }: IProps) => { return; } - window.ApiClient.getUser(userId).then(function (user) { - Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) { - if (!user.Policy) { - throw new Error('Unexpected null user.Policy'); - } + const user = await window.ApiClient.getUser(userId); + const loggedInUser = await Dashboard.getCurrentUser(); - if (!user.Configuration) { - throw new Error('Unexpected null user.Configuration'); - } + if (!user.Policy || !user.Configuration) { + throw new Error('Unexpected null user policy or configuration'); + } - LibraryMenu.setTitle(user.Name); + LibraryMenu.setTitle(user.Name); - let showLocalAccessSection = false; + let showLocalAccessSection = false; - if (user.HasConfiguredPassword) { - (page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide'); - (page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide'); - showLocalAccessSection = true; - } else { - (page.querySelector('#btnResetPassword') as HTMLDivElement).classList.add('hide'); - (page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide'); - } + if (user.HasConfiguredPassword) { + (page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide'); + (page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide'); + showLocalAccessSection = true; + } else { + (page.querySelector('#btnResetPassword') as HTMLDivElement).classList.add('hide'); + (page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide'); + } - if (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess) { - (page.querySelector('.passwordSection') as HTMLDivElement).classList.remove('hide'); - } else { - (page.querySelector('.passwordSection') as HTMLDivElement).classList.add('hide'); - } + const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess; + (page.querySelector('.passwordSection') as HTMLDivElement).classList.toggle('hide', !canChangePassword); + (page.querySelector('.localAccessSection') as HTMLDivElement).classList.toggle('hide', !(showLocalAccessSection && canChangePassword)); - if (showLocalAccessSection && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) { - (page.querySelector('.localAccessSection') as HTMLDivElement).classList.remove('hide'); - } else { - (page.querySelector('.localAccessSection') as HTMLDivElement).classList.add('hide'); - } + const txtEasyPassword = page.querySelector('#txtEasyPassword') as HTMLInputElement; + txtEasyPassword.value = ''; - const txtEasyPassword = page.querySelector('#txtEasyPassword') as HTMLInputElement; - txtEasyPassword.value = ''; + if (user.HasConfiguredEasyPassword) { + txtEasyPassword.placeholder = '******'; + (page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.remove('hide'); + } else { + txtEasyPassword.removeAttribute('placeholder'); + txtEasyPassword.placeholder = ''; + (page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.add('hide'); + } - if (user.HasConfiguredEasyPassword) { - txtEasyPassword.placeholder = '******'; - (page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.remove('hide'); - } else { - txtEasyPassword.removeAttribute('placeholder'); - txtEasyPassword.placeholder = ''; - (page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.add('hide'); - } + const chkEnableLocalEasyPassword = page.querySelector('.chkEnableLocalEasyPassword') as HTMLInputElement; - const chkEnableLocalEasyPassword = page.querySelector('.chkEnableLocalEasyPassword') as HTMLInputElement; + chkEnableLocalEasyPassword.checked = user.Configuration.EnableLocalPassword || false; - chkEnableLocalEasyPassword.checked = user.Configuration.EnableLocalPassword || false; - - import('../../autoFocuser').then(({ default: autoFocuser }) => { - autoFocuser.autoFocus(page); - }); - }); + import('../../autoFocuser').then(({ default: autoFocuser }) => { + autoFocuser.autoFocus(page); }); (page.querySelector('#txtCurrentPassword') as HTMLInputElement).value = ''; diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index bc3528261d..720cee0e3c 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,7 +1,7 @@ -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import type { ApiClient } from 'jellyfin-apiclient'; import classNames from 'classnames'; -import React, { FunctionComponent, useEffect, useState } from 'react'; +import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; import globalize from '../../scripts/globalize'; import ServerConnections from '../ServerConnections'; @@ -14,6 +14,17 @@ type SearchResultsProps = { query?: string; } +const ensureNonNullItems = (result: BaseItemDtoQueryResult) => ({ + ...result, + Items: result.Items || [] +}); + +const isMovies = (collectionType: string) => collectionType === 'movies'; + +const isMusic = (collectionType: string) => collectionType === 'music'; + +const isTVShows = (collectionType: string) => collectionType === 'tvshows' || collectionType === 'tv'; + /* * React component to display search result rows for global search and non-live tv library search */ @@ -35,55 +46,55 @@ const SearchResults: FunctionComponent = ({ serverId = windo const [ people, setPeople ] = useState([]); const [ collections, setCollections ] = useState([]); - useEffect(() => { - const getDefaultParameters = () => ({ - ParentId: parentId, - searchTerm: query, - Limit: 24, - Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount', - Recursive: true, - EnableTotalRecordCount: false, - ImageTypeLimit: 1, - IncludePeople: false, - IncludeMedia: false, - IncludeGenres: false, - IncludeStudios: false, - IncludeArtists: false - }); + const getDefaultParameters = useCallback(() => ({ + ParentId: parentId, + searchTerm: query, + Limit: 24, + Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount', + Recursive: true, + EnableTotalRecordCount: false, + ImageTypeLimit: 1, + IncludePeople: false, + IncludeMedia: false, + IncludeGenres: false, + IncludeStudios: false, + IncludeArtists: false + }), [parentId, query]); - const fetchArtists = (apiClient: ApiClient, params = {}) => apiClient?.getArtists( - apiClient?.getCurrentUserId(), + const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => ( + apiClient?.getArtists( + apiClient.getCurrentUserId(), { ...getDefaultParameters(), IncludeArtists: true, ...params } - ); + ).then(ensureNonNullItems) + ), [getDefaultParameters]); - const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems( - apiClient?.getCurrentUserId(), + const fetchItems = useCallback((apiClient: ApiClient, params = {}) => ( + apiClient?.getItems( + apiClient.getCurrentUserId(), { ...getDefaultParameters(), IncludeMedia: true, ...params } - ); + ).then(ensureNonNullItems) + ), [getDefaultParameters]); - const fetchPeople = (apiClient: ApiClient, params = {}) => apiClient?.getPeople( - apiClient?.getCurrentUserId(), + const fetchPeople = useCallback((apiClient: ApiClient, params = {}) => ( + apiClient?.getPeople( + apiClient.getCurrentUserId(), { ...getDefaultParameters(), IncludePeople: true, ...params } - ); - - const isMovies = () => collectionType === 'movies'; - - const isMusic = () => collectionType === 'music'; - - const isTVShows = () => collectionType === 'tvshows' || collectionType === 'tv'; + ).then(ensureNonNullItems) + ), [getDefaultParameters]); + useEffect(() => { // Reset state setMovies([]); setShows([]); @@ -102,78 +113,80 @@ const SearchResults: FunctionComponent = ({ serverId = windo setPeople([]); setCollections([]); - if (query) { - const apiClient = ServerConnections.getApiClient(serverId); - - // Movie libraries - if (!collectionType || isMovies()) { - // Movies row - fetchItems(apiClient, { IncludeItemTypes: 'Movie' }) - .then(result => setMovies(result.Items || [])); - } - - // TV Show libraries - if (!collectionType || isTVShows()) { - // Shows row - fetchItems(apiClient, { IncludeItemTypes: 'Series' }) - .then(result => setShows(result.Items || [])); - // Episodes row - fetchItems(apiClient, { IncludeItemTypes: 'Episode' }) - .then(result => setEpisodes(result.Items || [])); - } - - // People are included for Movies and TV Shows - if (!collectionType || isMovies() || isTVShows()) { - // People row - fetchPeople(apiClient).then(result => setPeople(result.Items || [])); - } - - // Music libraries - if (!collectionType || isMusic()) { - // Playlists row - fetchItems(apiClient, { IncludeItemTypes: 'Playlist' }) - .then(results => setPlaylists(results.Items || [])); - // Artists row - fetchArtists(apiClient).then(result => setArtists(result.Items || [])); - // Albums row - fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' }) - .then(result => setAlbums(result.Items || [])); - // Songs row - fetchItems(apiClient, { IncludeItemTypes: 'Audio' }) - .then(result => setSongs(result.Items || [])); - } - - // Other libraries do not support in-library search currently - if (!collectionType) { - // Videos row - fetchItems(apiClient, { - MediaTypes: 'Video', - ExcludeItemTypes: 'Movie,Episode,TvChannel' - }).then(result => setVideos(result.Items || [])); - // Programs row - fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' }) - .then(result => setPrograms(result.Items || [])); - // Channels row - fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) - .then(result => setChannels(result.Items || [])); - // Photo Albums row - fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' }) - .then(results => setPhotoAlbums(results.Items || [])); - // Photos row - fetchItems(apiClient, { IncludeItemTypes: 'Photo' }) - .then(results => setPhotos(results.Items || [])); - // Audio Books row - fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' }) - .then(results => setAudioBooks(results.Items || [])); - // Books row - fetchItems(apiClient, { IncludeItemTypes: 'Book' }) - .then(results => setBooks(results.Items || [])); - // Collections row - fetchItems(apiClient, { IncludeItemTypes: 'BoxSet' }) - .then(result => setCollections(result.Items || [])); - } + if (!query) { + return; } - }, [collectionType, parentId, query, serverId]); + + const apiClient = ServerConnections.getApiClient(serverId); + + // Movie libraries + if (!collectionType || isMovies(collectionType)) { + // Movies row + fetchItems(apiClient, { IncludeItemTypes: 'Movie' }) + .then(result => setMovies(result.Items)); + } + + // TV Show libraries + if (!collectionType || isTVShows(collectionType)) { + // Shows row + fetchItems(apiClient, { IncludeItemTypes: 'Series' }) + .then(result => setShows(result.Items)); + // Episodes row + fetchItems(apiClient, { IncludeItemTypes: 'Episode' }) + .then(result => setEpisodes(result.Items)); + } + + // People are included for Movies and TV Shows + if (!collectionType || isMovies(collectionType) || isTVShows(collectionType)) { + // People row + fetchPeople(apiClient).then(result => setPeople(result.Items)); + } + + // Music libraries + if (!collectionType || isMusic(collectionType)) { + // Playlists row + fetchItems(apiClient, { IncludeItemTypes: 'Playlist' }) + .then(results => setPlaylists(results.Items)); + // Artists row + fetchArtists(apiClient).then(result => setArtists(result.Items)); + // Albums row + fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' }) + .then(result => setAlbums(result.Items)); + // Songs row + fetchItems(apiClient, { IncludeItemTypes: 'Audio' }) + .then(result => setSongs(result.Items)); + } + + // Other libraries do not support in-library search currently + if (!collectionType) { + // Videos row + fetchItems(apiClient, { + MediaTypes: 'Video', + ExcludeItemTypes: 'Movie,Episode,TvChannel' + }).then(result => setVideos(result.Items)); + // Programs row + fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' }) + .then(result => setPrograms(result.Items)); + // Channels row + fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) + .then(result => setChannels(result.Items)); + // Photo Albums row + fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' }) + .then(result => setPhotoAlbums(result.Items)); + // Photos row + fetchItems(apiClient, { IncludeItemTypes: 'Photo' }) + .then(result => setPhotos(result.Items)); + // Audio Books row + fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' }) + .then(result => setAudioBooks(result.Items)); + // Books row + fetchItems(apiClient, { IncludeItemTypes: 'Book' }) + .then(result => setBooks(result.Items)); + // Collections row + fetchItems(apiClient, { IncludeItemTypes: 'BoxSet' }) + .then(result => setCollections(result.Items)); + } + }, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]); return (
= ({ }; viewManager.tryRestoreView(viewOptions) - .catch(async (result?: any) => { + .catch(async (result?: RestoreViewFailResponse) => { if (!result || !result.cancelled) { const [ controllerFactory, viewHtml ] = await Promise.all([ import(/* webpackChunkName: "[request]" */ `../../controllers/${controller}`), @@ -63,7 +64,10 @@ const ViewManagerPage: FunctionComponent = ({ }; loadPage(); - }, [ + }, + // location.state is NOT included as a dependency here since dialogs will update state while the current view stays the same + // eslint-disable-next-line react-hooks/exhaustive-deps + [ controller, view, type, @@ -73,8 +77,6 @@ const ViewManagerPage: FunctionComponent = ({ transition, location.pathname, location.search - // location.state is NOT included as a dependency here since dialogs will update state while the current view - // stays the same ]); return <>; diff --git a/src/elements/emby-scroller/Scroller.tsx b/src/elements/emby-scroller/Scroller.tsx index 36b38cc1fb..773d184c59 100644 --- a/src/elements/emby-scroller/Scroller.tsx +++ b/src/elements/emby-scroller/Scroller.tsx @@ -126,7 +126,7 @@ const Scroller: FC = ({ }, [getScrollPosition, getScrollSize, getScrollWidth]); const initCenterFocus = useCallback((elem: EventTarget, scrollerInstance: scrollerFactory) => { - dom.addEventListener(elem, 'focus', function (e: { target: any; }) { + dom.addEventListener(elem, 'focus', function (e: FocusEvent) { const focused = focusManager.focusableParent(e.target); if (focused) { scrollerInstance.toCenter(focused, false); diff --git a/src/routes/user/useredit.tsx b/src/routes/user/useredit.tsx index 67fe055632..64e70f32f6 100644 --- a/src/routes/user/useredit.tsx +++ b/src/routes/user/useredit.tsx @@ -25,6 +25,17 @@ type AuthProvider = { Id?: string; } +const getCheckedElementDataIds = (elements: NodeListOf) => ( + Array.prototype.filter.call(elements, e => e.checked) + .map(e => e.getAttribute('data-id')) +); + +function onSaveComplete() { + Dashboard.navigate('userprofiles.html'); + loading.hide(); + toast(globalize.translate('SettingsSaved')); +} + const UserEdit: FunctionComponent = () => { const [ userName, setUserName ] = useState(''); const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState([]); @@ -56,7 +67,7 @@ const UserEdit: FunctionComponent = () => { } const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement; - providers.length > 1 ? fldSelectLoginProvider.classList.remove('hide') : fldSelectLoginProvider.classList.add('hide'); + fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1); setAuthProviders(providers); @@ -73,7 +84,7 @@ const UserEdit: FunctionComponent = () => { } const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement; - providers.length > 1 ? fldSelectPasswordResetProvider.classList.remove('hide') : fldSelectPasswordResetProvider.classList.add('hide'); + fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1); setPasswordResetProviders(providers); @@ -145,7 +156,7 @@ const UserEdit: FunctionComponent = () => { }); const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement; - user.Policy.IsDisabled ? disabledUserBanner.classList.remove('hide') : disabledUserBanner.classList.add('hide'); + disabledUserBanner.classList.toggle('hide', !user.Policy.IsDisabled); const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement; txtUserName.disabled = false; @@ -198,19 +209,9 @@ const UserEdit: FunctionComponent = () => { loadData(); - function onSaveComplete() { - Dashboard.navigate('userprofiles.html'); - loading.hide(); - toast(globalize.translate('SettingsSaved')); - } - const saveUser = (user: UserDto) => { - if (!user.Id) { - throw new Error('Unexpected null user.Id'); - } - - if (!user.Policy) { - throw new Error('Unexpected null user.Policy'); + if (!user.Id || !user.Policy) { + throw new Error('Unexpected null user id or policy'); } user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value; @@ -235,19 +236,15 @@ const UserEdit: FunctionComponent = () => { user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value; user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value; user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked; - user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (c) { - return c.checked; - }).map(function (c) { - return c.getAttribute('data-id'); - }); - if (window.ApiClient.isMinServerVersion('10.6.0')) { - user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType; - } - window.ApiClient.updateUser(user).then(function () { - window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {}).then(function () { + user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder')); + user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType; + + window.ApiClient.updateUser(user) + .then(() => ( + window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {}) + )).then(() => { onSaveComplete(); }); - }); }; const onSubmit = (e: Event) => { @@ -261,16 +258,11 @@ const UserEdit: FunctionComponent = () => { }; (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) { - if (this.checked) { - (page.querySelector('.deleteAccess') as HTMLDivElement).classList.add('hide'); - } else { - (page.querySelector('.deleteAccess') as HTMLDivElement).classList.remove('hide'); - } + (page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked); }); window.ApiClient.getNamedConfiguration('network').then(function (config) { - const fldRemoteAccess = page.querySelector('.fldRemoteAccess') as HTMLDivElement; - config.EnableRemoteAccess ? fldRemoteAccess.classList.remove('hide') : fldRemoteAccess.classList.add('hide'); + (page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess); }); (page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit); diff --git a/src/routes/user/usernew.tsx b/src/routes/user/usernew.tsx index e3610b07f5..25d1a13140 100644 --- a/src/routes/user/usernew.tsx +++ b/src/routes/user/usernew.tsx @@ -111,12 +111,8 @@ const UserNew: FunctionComponent = () => { userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value; userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value; window.ApiClient.createUser(userInput).then(function (user) { - if (!user.Id) { - throw new Error('Unexpected null user.Id'); - } - - if (!user.Policy) { - throw new Error('Unexpected null user.Policy'); + if (!user.Id || !user.Policy) { + throw new Error('Unexpected null user id or policy'); } user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked; diff --git a/src/routes/user/userparentalcontrol.tsx b/src/routes/user/userparentalcontrol.tsx index dcf465a773..be28cf5089 100644 --- a/src/routes/user/userparentalcontrol.tsx +++ b/src/routes/user/userparentalcontrol.tsx @@ -215,12 +215,8 @@ const UserParentalControl: FunctionComponent = () => { }; const saveUser = (user: UserDto) => { - if (!user.Id) { - throw new Error('Unexpected null user.Id'); - } - - if (!user.Policy) { - throw new Error('Unexpected null user.Policy'); + if (!user.Id || !user.Policy) { + throw new Error('Unexpected null user id or policy'); } const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10); diff --git a/src/routes/user/userprofile.tsx b/src/routes/user/userprofile.tsx index fb1b5d08f3..0b35d50943 100644 --- a/src/routes/user/userprofile.tsx +++ b/src/routes/user/userprofile.tsx @@ -30,12 +30,8 @@ const UserProfile: FunctionComponent = () => { loading.show(); window.ApiClient.getUser(userId).then(function (user) { - if (!user.Name) { - throw new Error('Unexpected null user.Name'); - } - - if (!user.Id) { - throw new Error('Unexpected null user.Id'); + if (!user.Name || !user.Id) { + throw new Error('Unexpected null user name or id'); } setUserName(user.Name); diff --git a/src/types/viewManager.ts b/src/types/viewManager.ts new file mode 100644 index 0000000000..541bbdbc75 --- /dev/null +++ b/src/types/viewManager.ts @@ -0,0 +1,3 @@ +export interface RestoreViewFailResponse { + cancelled?: boolean +} diff --git a/src/utils/jellyfin-apiclient/getItems.ts b/src/utils/jellyfin-apiclient/getItems.ts index dee681a5d5..4bbe711f81 100644 --- a/src/utils/jellyfin-apiclient/getItems.ts +++ b/src/utils/jellyfin-apiclient/getItems.ts @@ -31,25 +31,26 @@ function getItemsSplit(apiClient: ApiClient, userId: string, options: GetItemsRe function mergeResults(results: BaseItemDtoQueryResult[]) { const merged: BaseItemDtoQueryResult = { Items: [], - TotalRecordCount: 0, StartIndex: 0 }; + // set TotalRecordCount separately so TS knows it is defined + merged.TotalRecordCount = 0; for (const result of results) { - if (result.Items == null) { + if (!result.Items) { console.log('[getItems] Retrieved Items array is invalid', result.Items); continue; } - if (result.TotalRecordCount == null) { + if (!result.TotalRecordCount) { console.log('[getItems] Retrieved TotalRecordCount is invalid', result.TotalRecordCount); continue; } - if (result.StartIndex == null) { + if (typeof result.StartIndex === 'undefined') { console.log('[getItems] Retrieved StartIndex is invalid', result.StartIndex); continue; } merged.Items = merged.Items?.concat(result.Items); - merged.TotalRecordCount! += result.TotalRecordCount; + merged.TotalRecordCount += result.TotalRecordCount; merged.StartIndex = Math.min(merged.StartIndex || 0, result.StartIndex); } return merged;