diff --git a/web/src/App.tsx b/web/src/App.tsx index d732ec4cb..15b3fafa2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -43,20 +43,13 @@ import useMediaQuery from '@mui/material/useMediaQuery'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { isNull, isUndefined } from 'lodash-es'; import React, { ReactNode, useCallback, useMemo, useState } from 'react'; -import { - Outlet, - Link as RouterLink, - useLocation, - useNavigate, -} from 'react-router-dom'; +import { Outlet, Link as RouterLink } from 'react-router-dom'; import './App.css'; import ServerEvents from './components/ServerEvents.tsx'; import TunarrLogo from './components/TunarrLogo.tsx'; import VersionFooter from './components/VersionFooter.tsx'; -import SelectedProgrammingList from './components/channel_config/SelectedProgrammingList.tsx'; import DarkModeButton from './components/settings/DarkModeButton.tsx'; import { useVersion } from './hooks/useVersion.ts'; -import { addMediaToCurrentChannel } from './store/channelEditor/actions.ts'; import useStore from './store/index.ts'; import { useSettings } from './store/settings/selectors.ts'; import { setDarkModeState } from './store/themeEditor/actions.ts'; @@ -120,18 +113,6 @@ export function Root({ children }: { children?: React.ReactNode }) { ); const [anchorEl, setAnchorEl] = useState(null); const mobileLinksOpen = !isNull(anchorEl); - const navigate = useNavigate(); - const location = useLocation(); - - const displayPaths = [ - '/programming/add', - 'library/custom-shows/new', - 'library/fillers/new', - ]; - - const displaySelectedProgramming = displayPaths.some((path) => - location.pathname.match(new RegExp(path)), - ); const toggleDrawerOpen = () => { setOpen(true); @@ -297,7 +278,7 @@ export function Root({ children }: { children?: React.ReactNode }) { icon: , }, ], - [mobileLinksOpen], + [settings.backendUri], ); const handleOpenClick = useCallback((itemName: string) => { @@ -553,13 +534,6 @@ export function Root({ children }: { children?: React.ReactNode }) { {children ?? } - - {displaySelectedProgramming && ( - navigate(-1)} - /> - )} diff --git a/web/src/components/channel_config/AddSelectedMediaButton.tsx b/web/src/components/channel_config/AddSelectedMediaButton.tsx index 78d343891..9df47d359 100644 --- a/web/src/components/channel_config/AddSelectedMediaButton.tsx +++ b/web/src/components/channel_config/AddSelectedMediaButton.tsx @@ -2,12 +2,12 @@ import { AddCircle } from '@mui/icons-material'; import { CircularProgress, Tooltip } from '@mui/material'; import Button, { ButtonProps } from '@mui/material/Button'; import { flattenDeep, map } from 'lodash-es'; -import { MouseEventHandler, useState } from 'react'; +import { MouseEventHandler, ReactNode, useState } from 'react'; import { forSelectedMediaType, sequentialPromises, } from '../../helpers/util.ts'; -import { enumeratePlexItem } from '../../hooks/plexHooks.ts'; +import { enumeratePlexItem } from '../../hooks/plex/plexHookUtil.ts'; import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; import useStore from '../../store/index.ts'; import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts'; @@ -17,11 +17,15 @@ import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts'; type Props = { onAdd: (items: AddedMedia[]) => void; onSuccess: () => void; + buttonText?: string; + tooltipTitle?: ReactNode; } & ButtonProps; export default function AddSelectedMediaButton({ onAdd, onSuccess, + buttonText, + tooltipTitle, ...rest }: Props) { const apiClient = useTunarrApi(); @@ -71,7 +75,7 @@ export default function AddSelectedMediaButton({ }; return ( - + diff --git a/web/src/components/channel_config/PlexDirectoryListItem.tsx b/web/src/components/channel_config/PlexDirectoryListItem.tsx index 32b197cb1..9e790f3ff 100644 --- a/web/src/components/channel_config/PlexDirectoryListItem.tsx +++ b/web/src/components/channel_config/PlexDirectoryListItem.tsx @@ -20,7 +20,7 @@ import { } from '@tunarr/types/plex'; import { take } from 'lodash-es'; import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { usePlexTyped2 } from '../../hooks/plexHooks.ts'; +import { usePlexTyped2 } from '../../hooks/plex/usePlex.ts'; import useStore from '../../store/index.ts'; import { addKnownMediaForServer, diff --git a/web/src/components/channel_config/PlexFilterBuilder.tsx b/web/src/components/channel_config/PlexFilterBuilder.tsx index 95d763b6b..162b91b3b 100644 --- a/web/src/components/channel_config/PlexFilterBuilder.tsx +++ b/web/src/components/channel_config/PlexFilterBuilder.tsx @@ -40,10 +40,8 @@ import { useForm, useFormContext, } from 'react-hook-form'; -import { - usePlexTags, - useSelectedLibraryPlexFilters, -} from '../../hooks/plexHooks.ts'; +import { usePlexTags } from '../../hooks/plex/usePlexTags.ts'; +import { useSelectedLibraryPlexFilters } from '../../hooks/plex/usePlexFilters.ts'; import useStore from '../../store/index.ts'; import { setPlexFilter } from '../../store/programmingSelector/actions.ts'; @@ -110,7 +108,7 @@ export function PlexValueNode({ }, [selfValue.field, findPlexField]); const { data: plexTags, isLoading: plexTagsLoading } = usePlexTags( - plexFilter?.type === 'tag' ? plexFilter.key : '', + plexFilter?.type === 'tag' ? plexFilter.key.replace('show.', '') : '', ); const lookupFieldOperators = useCallback( @@ -459,11 +457,12 @@ export function PlexFilterBuilder( formMethods.reset({ type: 'value', op: '=', - field: 'title', + field: + selectedLibrary?.library.type === 'show' ? 'show.title' : 'title', value: '', }); } - }, [advanced, formMethods, formMethods.reset]); + }, [advanced, formMethods, formMethods.reset, selectedLibrary?.library.type]); return ( s.currentServer); const selectedLibrary = useStore((s) => @@ -228,38 +222,7 @@ export default function PlexProgrammingSelector() { fetchNextPage: fetchNextCollectionsPage, isFetchingNextPage: isFetchingNextCollectionsPage, hasNextPage: hasNextCollectionsPage, - } = useInfiniteQuery({ - queryKey: [ - 'plex', - selectedServer?.name, - selectedLibrary?.library.key, - 'collections', - ], - queryFn: ({ pageParam }) => { - const plexQuery = new URLSearchParams({ - 'X-Plex-Container-Start': pageParam.toString(), - 'X-Plex-Container-Size': (rowSize * 4).toString(), - }); - - return fetchPlexPath( - apiClient, - selectedServer!.name, - `/library/sections/${selectedLibrary?.library - .key}/collections?${plexQuery.toString()}`, - )(); - }, - enabled: !isNil(selectedServer) && !isNil(selectedLibrary), - initialPageParam: 0, - getNextPageParam: (res, all, last) => { - const total = sumBy(all, (page) => page.size); - if (total >= (res.totalSize ?? res.size)) { - return null; - } - - // Next offset is the last + how many items we got back. - return last + res.size; - }, - }); + } = usePlexCollectionsInfinite(selectedServer, selectedLibrary, rowSize * 4); useEffect(() => { // When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab @@ -278,51 +241,12 @@ export default function PlexProgrammingSelector() { fetchNextPage: fetchNextItemsPage, hasNextPage: hasNextItemsPage, isFetchingNextPage: isFetchingNextItemsPage, - } = useInfiniteQuery({ - queryKey: [ - 'plex-search', - selectedServer?.name, - selectedLibrary?.library.key, - searchKey, - ] as DataTag< - ['plex-search', string, string, string], - PlexLibraryMovies | PlexLibraryShows | PlexLibraryMusic - >, - enabled: !isNil(selectedServer) && !isNil(selectedLibrary), - initialPageParam: 0, - queryFn: ({ pageParam }) => { - const plexQuery = new URLSearchParams({ - 'X-Plex-Container-Start': pageParam.toString(), - 'X-Plex-Container-Size': (rowSize * 4).toString(), - }); - // HACK for now - forEach(searchKey?.split('&'), (keyval) => { - const idx = keyval.lastIndexOf('='); - if (idx !== -1) { - plexQuery.append(keyval.substring(0, idx), keyval.substring(idx + 1)); - } - }); - - return fetchPlexPath< - PlexLibraryMovies | PlexLibraryShows | PlexLibraryMusic - >( - apiClient, - selectedServer!.name, - `/library/sections/${ - selectedLibrary!.library.key - }/all?${plexQuery.toString()}`, - )(); - }, - getNextPageParam: (res, all, last) => { - const total = sumBy(all, (page) => page.size); - if (total >= (res.totalSize ?? res.size)) { - return null; - } - - // Next offset is the last + how many items we got back. - return last + res.size; - }, - }); + } = usePlexSearchInfinite( + selectedServer, + selectedLibrary, + searchKey, + rowSize * 4, + ); useEffect(() => { if (searchData?.pages.length === 1) { diff --git a/web/src/components/channel_config/PlexSortField.tsx b/web/src/components/channel_config/PlexSortField.tsx index f639ae35c..464bc4836 100644 --- a/web/src/components/channel_config/PlexSortField.tsx +++ b/web/src/components/channel_config/PlexSortField.tsx @@ -8,7 +8,7 @@ import find from 'lodash-es/find'; import isUndefined from 'lodash-es/isUndefined'; import map from 'lodash-es/map'; import { useCallback, useEffect, useState } from 'react'; -import { usePlexFilters } from '../../hooks/plexHooks'; +import { usePlexFilters } from '../../hooks/plex/usePlexFilters'; import useStore from '../../store'; import { setPlexSort } from '../../store/programmingSelector/actions'; diff --git a/web/src/components/channel_config/ProgrammingSelector.tsx b/web/src/components/channel_config/ProgrammingSelector.tsx index 3941f6d0a..1dc812944 100644 --- a/web/src/components/channel_config/ProgrammingSelector.tsx +++ b/web/src/components/channel_config/ProgrammingSelector.tsx @@ -11,7 +11,7 @@ import { import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex'; import { find, isEmpty, isNil, isUndefined, map } from 'lodash-es'; import React, { useCallback, useEffect, useState } from 'react'; -import { usePlexLibraries } from '../../hooks/plexHooks.ts'; +import { usePlexLibraries } from '../../hooks/plex/usePlex.ts'; import { usePlexServerSettings } from '../../hooks/settingsHooks.ts'; import { useCustomShows } from '../../hooks/useCustomShows.ts'; import useStore from '../../store/index.ts'; @@ -149,58 +149,62 @@ export default function ProgrammingSelector() { (plexServers && plexServers.length > 0) || customShows.length > 0; return ( - - - {hasAnySources && ( - - Media Source - - - )} - - {!isNil(plexLibraryChildren) && - plexLibraryChildren.size > 0 && - selectedPlexLibrary && ( + + + + {hasAnySources && ( - Library + Media Source )} - - {renderMediaSourcePrograms()} + + {!isNil(plexLibraryChildren) && + plexLibraryChildren.size > 0 && + selectedPlexLibrary && ( + + Library + + + )} + + {renderMediaSourcePrograms()} + ); } diff --git a/web/src/components/channel_config/SelectedProgrammingActions.tsx b/web/src/components/channel_config/SelectedProgrammingActions.tsx index 8078ee4cc..c5825be50 100644 --- a/web/src/components/channel_config/SelectedProgrammingActions.tsx +++ b/web/src/components/channel_config/SelectedProgrammingActions.tsx @@ -1,111 +1,195 @@ -import { Delete, MenuOpen } from '@mui/icons-material'; +import { Delete, DoneAll, Grading } from '@mui/icons-material'; import { - Box, + Alert, Button, + Paper, + Snackbar, Tooltip, - Typography, useMediaQuery, useTheme, } from '@mui/material'; -import { reduce } from 'lodash-es'; -import pluralize from 'pluralize'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import useStore from '../../store/index.ts'; -import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts'; +import { + addKnownMediaForServer, + addPlexSelectedMedia, + clearSelectedMedia, +} from '../../store/programmingSelector/actions.ts'; import { AddedMedia } from '../../types/index.ts'; +import { isNil, isNull } from 'lodash-es'; +import { useDirectPlexSearch } from '@/hooks/plex/usePlexSearch.ts'; +import { RotatingLoopIcon } from '../base/LoadingIcon.tsx'; +import { Nullable } from 'vitest'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; type Props = { onAddSelectedMedia: (media: AddedMedia[]) => void; onAddMediaSuccess: () => void; - onSelectionModalClose: () => void; + toggleOrSetSelectedProgramsDrawer: (open?: boolean) => void; + onSelectionModalClose?: () => void; + selectAllEnabled?: boolean; }; export default function SelectedProgrammingActions({ onAddSelectedMedia, onAddMediaSuccess, - onSelectionModalClose, + selectAllEnabled = true, + toggleOrSetSelectedProgramsDrawer, // onSelectionModalClose, }: Props) { + const [selectedServer, selectedLibrary] = useStore((s) => [ + s.currentServer, + s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, + ]); + + const { urlFilter: plexSearch } = useStore( + ({ plexSearch: plexQuery }) => plexQuery, + ); + const selectedMedia = useStore((s) => s.selectedMedia); const theme = useTheme(); const smallViewport = useMediaQuery(theme.breakpoints.down('sm')); - - const totalCount = reduce( - selectedMedia, - (acc, media) => acc + (media.childCount ?? 1), - 0, - ); + const [selectAllLoading, setSelectAllLoading] = useState(false); + // TODO: Have a centralized place where these fire off and + // use a hook to just send a message to the queue. + const [errorSnackbarMessage, setErrorSnackbarMessage] = + useState>(null); const removeAllItems = useCallback(() => { + toggleOrSetSelectedProgramsDrawer(false); clearSelectedMedia(); - }, []); + }, [toggleOrSetSelectedProgramsDrawer]); + + const directPlexSearchFn = useDirectPlexSearch( + selectedServer, + selectedLibrary, + plexSearch, + true, + ); + + const selectAllItems = () => { + if ( + !isNil(selectedServer) && + !isNil(selectedLibrary) && + selectedLibrary.type === 'plex' + ) { + setSelectAllLoading(true); + directPlexSearchFn() + .then((response) => { + addKnownMediaForServer(selectedServer.name, response.Metadata ?? []); + addPlexSelectedMedia(selectedServer.name, response.Metadata); + }) + .catch((e) => { + console.error('Error while attempting to select all Plex items', e); + setErrorSnackbarMessage( + 'Error querying Plex. Check console log and consider reporting a bug!', + ); + }) + .finally(() => setSelectAllLoading(false)); + } + }; return ( - selectedMedia.length > 0 && ( - + setErrorSnackbarMessage(null)} + > + + {errorSnackbarMessage} + + + - {smallViewport ? ( - - ) : ( - - {totalCount} Selected {pluralize('Item', totalCount)} - + + + )} + {selectAllEnabled && ( + + + )} - - + + )} + + {!smallViewport && ( + removeAllItems()} - > - Unselect All - - - - - - ) + /> + )} + + ); } diff --git a/web/src/components/channel_config/SelectedProgrammingList.tsx b/web/src/components/channel_config/SelectedProgrammingList.tsx index 9575aa91b..26a9f5e00 100644 --- a/web/src/components/channel_config/SelectedProgrammingList.tsx +++ b/web/src/components/channel_config/SelectedProgrammingList.tsx @@ -1,33 +1,49 @@ -import { Close as RemoveIcon } from '@mui/icons-material'; import { + KeyboardArrowLeft, + KeyboardArrowRight, + Close as RemoveIcon, +} from '@mui/icons-material'; +import { + ClickAwayListener, Drawer, IconButton, ListItemText, + Paper, SwipeableDrawer, Toolbar, + Tooltip, Typography, styled, useMediaQuery, useTheme, } from '@mui/material'; -import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import { grey } from '@mui/material/colors'; -import { isPlexDirectory, isPlexSeason, isPlexShow } from '@tunarr/types/plex'; -import { chain, first, groupBy, mapValues, reduce } from 'lodash-es'; +import { + isPlexDirectory, + isPlexMovie, + isPlexSeason, + isPlexShow, +} from '@tunarr/types/plex'; +import { first, groupBy, isUndefined, mapValues, reduce } from 'lodash-es'; import pluralize from 'pluralize'; -import { useState } from 'react'; -import { forSelectedMediaType, unwrapNil } from '../../helpers/util.ts'; +import { ReactNode, useState } from 'react'; +import { forSelectedMediaType, toggle, unwrapNil } from '../../helpers/util.ts'; import { useCustomShows } from '../../hooks/useCustomShows.ts'; import useStore from '../../store/index.ts'; import { removeSelectedMedia } from '../../store/programmingSelector/actions.ts'; import { AddedMedia } from '../../types/index.ts'; import SelectedProgrammingActions from './SelectedProgrammingActions.tsx'; +import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; +import { SelectedMedia } from '@/store/programmingSelector/store.ts'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; +import { useWindowSize } from 'usehooks-ts'; type Props = { onAddSelectedMedia: (media: AddedMedia[]) => void; onAddMediaSuccess: () => void; + selectAllEnabled?: boolean; }; const StyledBox = styled('div')(({ theme }) => ({ @@ -47,6 +63,7 @@ const Puller = styled('div')(({ theme }) => ({ export default function SelectedProgrammingList({ onAddSelectedMedia, onAddMediaSuccess, + selectAllEnabled = true, }: Props) { const { data: customShows } = useCustomShows(); const knownMedia = useStore((s) => s.knownMediaByServer); @@ -54,9 +71,10 @@ export default function SelectedProgrammingList({ const theme = useTheme(); const smallViewport = useMediaQuery(theme.breakpoints.down('sm')); const [open, setOpen] = useState(false); + const windowSize = useWindowSize(); - const toggleDrawer = (newOpen: boolean) => () => { - setOpen(newOpen); + const toggleDrawer = (open?: boolean) => { + setOpen(isUndefined(open) ? toggle : open); }; const totalCount = reduce( @@ -70,32 +88,45 @@ export default function SelectedProgrammingList({ unwrapNil, ); - const renderSelectedMediaType = forSelectedMediaType({ - plex: (selected) => { + const renderSelectedMediaType = forSelectedMediaType< + JSX.Element, + [ListChildComponentProps] + >({ + plex: (selected, { index, style }) => { const media = knownMedia[selected.server][selected.guid]; let title: string = media.title; + let secondary: ReactNode = null; if (isPlexDirectory(media)) { + // TODO: Show the size title = `Library - ${media.title}`; } else if (isPlexShow(media)) { - title = `${media.title} (${media.childCount} ${pluralize( + secondary = `${media.childCount} ${pluralize( 'season', media.childCount, - )}, ${media.leafCount} total ${pluralize('episode', media.leafCount)})`; + )}, ${media.leafCount} total ${pluralize('episode', media.leafCount)}`; } else if (isPlexSeason(media)) { - title = `${media.parentTitle} - ${media.title} (${ + secondary = `${media.parentTitle} - ${media.title} (${ media.leafCount } ${pluralize('episode', media.leafCount)})`; } else if (media.type === 'collection') { - title = `${media.title} (${media.childCount} ${pluralize( + secondary = `${media.title} (${media.childCount} ${pluralize( 'item', parseInt(media.childCount), )})`; + } else if (isPlexMovie(media)) { + secondary = `Movie${media.year ? ', ' + media.year : ''}`; } return ( - - + + removeSelectedMedia([selected])}> @@ -104,28 +135,61 @@ export default function SelectedProgrammingList({ ); }, - 'custom-show': (selected, index: number) => { + 'custom-show': (selected, { index, style }) => { const customShow = customShowById[selected.customShowId]; return ( customShow && ( - - Custom Show {customShow.name} + + + + removeSelectedMedia([selected])}> + + + ) ); }, }); - const renderSelectedItems = () => { - const items = chain(selectedMedia) - .map((item, index) => renderSelectedMediaType(item, index)) - .compact() - .value(); + const getItemKey = (index: number, data: SelectedMedia[]) => { + const item = data[index]; + switch (item.type) { + case 'plex': + return item.guid; + case 'custom-show': + return `custom_${item.customShowId}_${index}`; + } + }; + const SelectedItemRow = (props: ListChildComponentProps) => { + return renderSelectedMediaType(selectedMedia[props.index], props); + }; + + const renderSelectedItems = () => { return ( - - {items} - + + {SelectedItemRow} + ); }; @@ -135,7 +199,8 @@ export default function SelectedProgrammingList({ ); @@ -145,13 +210,20 @@ export default function SelectedProgrammingList({ 0 && open} - onClose={toggleDrawer(false)} - onOpen={toggleDrawer(true)} + onClose={() => setOpen(false)} + onOpen={() => setOpen(true)} disableSwipeToOpen={false} swipeAreaWidth={50} ModalProps={{ keepMounted: true, }} + sx={{ + '& .MuiDrawer-paper': { + boxSizing: 'border-box', + px: 1, + py: 0, + }, + }} > - + Selected {pluralize('Item', totalCount)} ({totalCount}): + ( <> - - 0 && ( + - - Selected {pluralize('Item', totalCount)} ({totalCount}): - - {selectedMedia.length > 0 && renderSelectedItems()} - + > + + setOpen(toggle)}> + {open ? : } + + + + )} + + setOpen(false)}> + setOpen(false)} + PaperProps={{ elevation: 2 }} + sx={{ + width: drawerWidth, + px: 1, + flexShrink: 0, + '& .MuiDrawer-paper': { + width: drawerWidth, + boxSizing: 'border-box', + px: 1, + py: 0, + }, + WebkitTransitionDuration: '.15s', + WebkitTransitionTimingFunction: 'cubic-bezier(0.4,0,0.2,1)', + overflowX: 'hidden', + }} + > + + + Selected {pluralize('Item', totalCount)} ({totalCount}): + + + {selectedMedia.length > 0 && renderSelectedItems()} + + ); diff --git a/web/src/helpers/plexUtil.ts b/web/src/helpers/plexUtil.ts index e69de29bb..9ae27c448 100644 --- a/web/src/helpers/plexUtil.ts +++ b/web/src/helpers/plexUtil.ts @@ -0,0 +1,18 @@ +import { ApiClient } from '../external/api.ts'; + +export const fetchPlexPath = ( + apiClient: ApiClient, + serverName: string, + path: string, +) => { + return async () => { + return apiClient + .getPlexPath({ + queries: { + name: serverName, + path, + }, + }) + .then((r) => r as T); + }; +}; diff --git a/web/src/hooks/plex/plexHookUtil.ts b/web/src/hooks/plex/plexHookUtil.ts new file mode 100644 index 000000000..e4cdf56f6 --- /dev/null +++ b/web/src/hooks/plex/plexHookUtil.ts @@ -0,0 +1,100 @@ +import { + PlexEpisodeView, + PlexLibraryListing, + PlexLibrarySection, + PlexLibrarySections, + PlexMedia, + PlexSeasonView, + PlexTerminalMedia, + isPlexDirectory, + isTerminalItem, +} from '@tunarr/types/plex'; +import { flattenDeep, map } from 'lodash-es'; +import { ApiClient } from '../../external/api.ts'; +import { sequentialPromises } from '../../helpers/util.ts'; +import { createExternalId } from '@tunarr/shared'; +import { fetchPlexPath } from '../../helpers/plexUtil.ts'; + +export type PlexPathMappings = [ + ['/library/sections', PlexLibrarySections], + [`/library/sections/${string}/all`, unknown], +]; + +export const plexQueryOptions = ( + apiClient: ApiClient, + serverName: string, + path: string, + enabled: boolean = true, +) => ({ + queryKey: ['plex', serverName, path], + queryFn: fetchPlexPath(apiClient, serverName, path), + enabled: enabled && serverName.length > 0 && path.length > 0, +}); + +export type EnrichedPlexMedia = PlexTerminalMedia & { + // This is the Plex server name that the info was retrieved from + serverName: string; + // If we found an existing reference to this item on the server, we add it here + id?: string; + showId?: string; + seasonId?: string; +}; + +export const enumeratePlexItem = ( + apiClient: ApiClient, + serverName: string, + initialItem: PlexMedia | PlexLibrarySection, +): (() => Promise) => { + const fetchPlexPathFunc = (path: string) => + fetchPlexPath(apiClient, serverName, path)(); + + async function loopInner( + item: PlexMedia | PlexLibrarySection, + ): Promise { + if (isTerminalItem(item)) { + return [{ ...item, serverName }]; + } else { + const path = isPlexDirectory(item) + ? `/library/sections/${item.key}/all` + : item.key; + + return fetchPlexPathFunc< + PlexLibraryListing | PlexSeasonView | PlexEpisodeView + >(path) + .then(async (result) => { + return sequentialPromises(result.Metadata, loopInner); + }) + .then((allResults) => flattenDeep(allResults)); + } + } + + return async function () { + const res = await loopInner(initialItem); + const externalIds = res.map((m) => + createExternalId('plex', serverName, m.ratingKey), + ); + + // This is best effort - if the user saves these IDs later, the upsert + // logic should figure out what is new/existing + try { + const existingIdsByExternalId = + await apiClient.batchGetProgramsByExternalIds({ externalIds }); + return map(res, (media) => { + const existing = + existingIdsByExternalId[ + createExternalId('plex', serverName, media.ratingKey) + ]; + return { + ...media, + id: existing?.id, + showId: existing?.showId, + seasonId: existing?.seasonId, + }; + }); + } catch (e) { + console.error('Unable to retrieve IDs in batch', e); + } + + return res; + }; +}; diff --git a/web/src/hooks/plex/usePlex.ts b/web/src/hooks/plex/usePlex.ts new file mode 100644 index 000000000..1917b3d3e --- /dev/null +++ b/web/src/hooks/plex/usePlex.ts @@ -0,0 +1,74 @@ +import { useQueries, useQuery } from '@tanstack/react-query'; +import { PlexLibrarySections } from '@tunarr/types/plex'; +import { fetchPlexPath } from '../../helpers/plexUtil.ts'; +import { ExtractTypeKeys, FindChild } from '../../types/util.ts'; +import { useApiQuery } from '../useApiQuery.ts'; +import { useTunarrApi } from '../useTunarrApi.ts'; +import { plexQueryOptions } from './plexHookUtil.ts'; + +export type PlexPathMappings = [ + ['/library/sections', PlexLibrarySections], + [`/library/sections/${string}/all`, unknown], +]; + +declare const plexQueryArgsSymbol: unique symbol; + +type PlexQueryArgs = { + serverName: string; + path: string; + enabled: boolean; + [plexQueryArgsSymbol]?: T; +}; + +export const usePlex = < + T extends ExtractTypeKeys, + OutType = FindChild, +>( + serverName: string, + path: string, + enabled: boolean = true, +) => + useApiQuery({ + queryKey: ['plex', serverName, path], + queryFn: (apiClient) => + fetchPlexPath(apiClient, serverName, path)(), + enabled, + }); +export const usePlexTyped = ( + serverName: string, + path: string, + enabled: boolean = true, +) => { + const apiClient = useTunarrApi(); + return useQuery(plexQueryOptions(apiClient, serverName, path, enabled)); +}; /** + * Like {@link usePlexTyped} but accepts two queries that each return + * a typed Plex object. NOTE - uses casting and not schema validation!! + */ + +export const usePlexTyped2 = ( + args: [PlexQueryArgs, PlexQueryArgs], +) => { + const apiClient = useTunarrApi(); + return useQueries({ + queries: args.map((query) => ({ + queryKey: ['plex', query.serverName, query.path], + queryFn: fetchPlexPath<(typeof query)[typeof plexQueryArgsSymbol]>( + apiClient, + query.serverName, + query.path, + ), + enabled: query.enabled, + })), + combine: ([firstResult, secondResult]) => { + return { + first: firstResult.data as T | undefined, + second: secondResult.data as U | undefined, + isPending: firstResult.isPending || secondResult.isPending, + error: firstResult.error || secondResult.error, + }; + }, + }); +}; +export const usePlexLibraries = (serverName: string, enabled: boolean = true) => + usePlex<'/library/sections'>(serverName, '/library/sections', enabled); diff --git a/web/src/hooks/plex/usePlexCollections.ts b/web/src/hooks/plex/usePlexCollections.ts new file mode 100644 index 000000000..af2d6eb67 --- /dev/null +++ b/web/src/hooks/plex/usePlexCollections.ts @@ -0,0 +1,49 @@ +import { PlexLibrary } from '@/store/programmingSelector/store.ts'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { PlexServerSettings } from '@tunarr/types'; +import { PlexLibraryCollections } from '@tunarr/types/plex'; +import { isNil, sumBy } from 'lodash-es'; +import { fetchPlexPath } from '../../helpers/plexUtil.ts'; +import { useTunarrApi } from '../useTunarrApi.ts'; +import { Maybe, Nilable } from '@/types/util.ts'; + +export const usePlexCollectionsInfinite = ( + plexServer: Maybe, + currentLibrary: Nilable, + pageSize: number, +) => { + const apiClient = useTunarrApi(); + + return useInfiniteQuery({ + queryKey: [ + 'plex', + plexServer?.name, + currentLibrary?.library.key, + 'collections', + ], + queryFn: ({ pageParam }) => { + const plexQuery = new URLSearchParams({ + 'X-Plex-Container-Start': pageParam.toString(), + 'X-Plex-Container-Size': pageSize.toString(), + }); + + return fetchPlexPath( + apiClient, + plexServer!.name, + `/library/sections/${currentLibrary?.library + .key}/collections?${plexQuery.toString()}`, + )(); + }, + enabled: !isNil(plexServer) && !isNil(currentLibrary), + initialPageParam: 0, + getNextPageParam: (res, all, last) => { + const total = sumBy(all, (page) => page.size); + if (total >= (res.totalSize ?? res.size)) { + return null; + } + + // Next offset is the last + how many items we got back. + return last + res.size; + }, + }); +}; diff --git a/web/src/hooks/plex/usePlexFilters.ts b/web/src/hooks/plex/usePlexFilters.ts new file mode 100644 index 000000000..36894c4be --- /dev/null +++ b/web/src/hooks/plex/usePlexFilters.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; +import { PlexFiltersResponse } from '@tunarr/types/plex'; +import { useEffect } from 'react'; +import { setPlexMetadataFilters } from '@/store/plexMetadata/actions.ts'; +import useStore from '@/store/index.ts'; +import { useTunarrApi } from '@/hooks/useTunarrApi.ts'; +import { plexQueryOptions } from '@/hooks/plex/plexHookUtil.ts'; + +export const usePlexFilters = (serverName: string, plexKey: string) => { + const apiClient = useTunarrApi(); + const key = `/library/sections/${plexKey}/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0`; + const query = useQuery({ + ...plexQueryOptions( + apiClient, + serverName, + key, + serverName.length > 0 && plexKey.length > 0, + ), + staleTime: 1000 * 60 * 60 * 60, + }); + + useEffect(() => { + if (query.data) { + setPlexMetadataFilters(serverName, plexKey, query.data); + } + }, [serverName, plexKey, query.data]); + + return { + isLoading: query.isLoading, + error: query.error, + data: useStore(({ plexMetadata }) => { + const server = plexMetadata.libraryFilters[serverName]; + if (server) { + return server[plexKey]?.Meta; + } + }), + }; +}; + +// Like usePlexFilters, but uses the selected server and library from +// local state. +export const useSelectedLibraryPlexFilters = () => { + const selectedServer = useStore((s) => s.currentServer); + const selectedLibrary = useStore((s) => + s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, + ); + return usePlexFilters( + selectedServer?.name ?? '', + selectedLibrary?.library.key ?? '', + ); +}; diff --git a/web/src/hooks/plex/usePlexSearch.ts b/web/src/hooks/plex/usePlexSearch.ts new file mode 100644 index 000000000..5bf497f89 --- /dev/null +++ b/web/src/hooks/plex/usePlexSearch.ts @@ -0,0 +1,150 @@ +import { PlexLibrary } from '@/store/programmingSelector/store.ts'; +import { + DataTag, + useInfiniteQuery, + useQuery, + queryOptions, + useQueryClient, +} from '@tanstack/react-query'; +import { PlexServerSettings } from '@tunarr/types'; +import { + PlexLibraryMovies, + PlexLibraryMusic, + PlexLibraryShows, +} from '@tunarr/types/plex'; +import { forEach, isNil, isUndefined, sumBy } from 'lodash-es'; +import { fetchPlexPath } from '../../helpers/plexUtil.ts'; +import { useTunarrApi } from '../useTunarrApi.ts'; +import { Maybe, Nilable } from '@/types/util.ts'; + +const usePlexSearchQueryFn = () => { + const apiClient = useTunarrApi(); + + return ( + plexServer: PlexServerSettings, + plexLibrary: PlexLibrary, + searchParam: Maybe, + pageParams?: { start: number; size: number }, + ) => { + const plexQuery = new URLSearchParams(); + + if (!isUndefined(pageParams)) { + plexQuery.set('X-Plex-Container-Start', pageParams.start.toString()); + plexQuery.set('X-Plex-Container-Size', pageParams.size.toString()); + } + + // HACK for now + forEach(searchParam?.split('&'), (keyval) => { + const idx = keyval.lastIndexOf('='); + if (idx !== -1) { + plexQuery.append(keyval.substring(0, idx), keyval.substring(idx + 1)); + } + }); + + return fetchPlexPath< + PlexLibraryMovies | PlexLibraryShows | PlexLibraryMusic + >( + apiClient, + plexServer.name, + `/library/sections/${ + plexLibrary.library.key + }/all?${plexQuery.toString()}`, + )(); + }; +}; + +const usePlexSearchQueryOptions = ( + plexServer: Maybe, + currentLibrary: Nilable, + searchParam: Maybe, + enabled: boolean = true, +) => { + const plexQueryFn = usePlexSearchQueryFn(); + return queryOptions({ + queryKey: [ + 'plex-search', + plexServer?.name, + currentLibrary?.library.key, + searchParam, + ] as DataTag< + ['plex-search', string, string, string], + PlexLibraryMovies | PlexLibraryShows | PlexLibraryMusic + >, + enabled: enabled && !isNil(plexServer) && !isNil(currentLibrary), + queryFn: () => { + return plexQueryFn(plexServer!, currentLibrary!, searchParam); + }, + }); +}; + +export const useDirectPlexSearch = ( + plexServer: Maybe, + currentLibrary: Nilable, + searchParam: Maybe, + enabled: boolean = true, +) => { + const queryClient = useQueryClient(); + const options = usePlexSearchQueryOptions( + plexServer, + currentLibrary, + searchParam, + enabled, + ); + + return () => { + return queryClient.ensureQueryData(options); + }; +}; + +export const usePlexSearch = ( + plexServer: Maybe, + currentLibrary: Nilable, + searchParam: Maybe, + enabled: boolean = true, +) => { + const queryOptions = usePlexSearchQueryOptions( + plexServer, + currentLibrary, + searchParam, + enabled, + ); + + return useQuery(queryOptions); +}; + +export const usePlexSearchInfinite = ( + plexServer: Maybe, + currentLibrary: Nilable, + searchParam: Maybe, + pageSize: number, + enabled: boolean = true, +) => { + const plexQueryFn = usePlexSearchQueryFn(); + + return useInfiniteQuery({ + queryKey: [ + 'plex-search', + plexServer?.name, + currentLibrary?.library.key, + searchParam, + 'infinite', + ], + enabled: enabled && !isNil(plexServer) && !isNil(currentLibrary), + initialPageParam: 0, + queryFn: ({ pageParam }) => { + return plexQueryFn(plexServer!, currentLibrary!, searchParam, { + start: pageParam, + size: pageSize, + }); + }, + getNextPageParam: (res, all, last) => { + const total = sumBy(all, (page) => page.size); + if (total >= (res.totalSize ?? res.size)) { + return null; + } + + // Next offset is the last + how many items we got back. + return last + res.size; + }, + }); +}; diff --git a/web/src/hooks/plex/usePlexServerStatus.ts b/web/src/hooks/plex/usePlexServerStatus.ts new file mode 100644 index 000000000..7aca616be --- /dev/null +++ b/web/src/hooks/plex/usePlexServerStatus.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { DefaultPlexHeaders } from '@tunarr/shared/constants'; +import { PlexServerSettings } from '@tunarr/types'; +import axios from 'axios'; + +export const usePlexServerStatus = (server: PlexServerSettings) => { + return useQuery({ + queryKey: ['plex-servers', server.id, 'status-local'], + queryFn: async () => { + try { + await axios.get(`${server.uri}`, { + headers: { + ...DefaultPlexHeaders, + 'X-Plex-Token': server.accessToken, + }, + timeout: 30 * 1000, + }); + return true; + } catch (e) { + return false; + } + }, + }); +}; diff --git a/web/src/hooks/plex/usePlexTags.ts b/web/src/hooks/plex/usePlexTags.ts new file mode 100644 index 000000000..d0b1b97cd --- /dev/null +++ b/web/src/hooks/plex/usePlexTags.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { PlexTagResult } from '@tunarr/types/plex'; +import useStore from '../../store/index.ts'; +import { useTunarrApi } from '../useTunarrApi.ts'; +import { plexQueryOptions } from './plexHookUtil.ts'; + +export const usePlexTags = (key: string) => { + const apiClient = useTunarrApi(); + const selectedServer = useStore((s) => s.currentServer); + const selectedLibrary = useStore((s) => + s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, + ); + const path = selectedLibrary + ? `/library/sections/${selectedLibrary.library.key}/${key}` + : ''; + + return useQuery({ + ...plexQueryOptions(apiClient, selectedServer?.name ?? '', path), + }); +}; diff --git a/web/src/hooks/plexHooks.ts b/web/src/hooks/plexHooks.ts deleted file mode 100644 index d0294bda7..000000000 --- a/web/src/hooks/plexHooks.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { useQueries, useQuery } from '@tanstack/react-query'; -import { DefaultPlexHeaders } from '@tunarr/shared/constants'; -import { PlexServerSettings } from '@tunarr/types'; -import { - PlexEpisodeView, - PlexFiltersResponse, - PlexLibraryListing, - PlexLibrarySection, - PlexLibrarySections, - PlexMedia, - PlexSeasonView, - PlexTagResult, - PlexTerminalMedia, - isPlexDirectory, - isTerminalItem, -} from '@tunarr/types/plex'; -import axios from 'axios'; -import { flattenDeep, map } from 'lodash-es'; -import { useEffect } from 'react'; -import { ApiClient } from '../external/api.ts'; -import { sequentialPromises } from '../helpers/util.ts'; -import useStore from '../store/index.ts'; -import { setPlexMetadataFilters } from '../store/plexMetadata/actions.ts'; -import { useApiQuery } from './useApiQuery.ts'; -import { useTunarrApi } from './useTunarrApi.ts'; - -type PlexPathMappings = [ - ['/library/sections', PlexLibrarySections], - [`/library/sections/${string}/all`, unknown], -]; - -type FindChild0 = Arr extends [ - [infer Head, infer Child], - ...infer Tail, -] - ? Head extends Target - ? Child - : FindChild0 - : never; - -// Turns a key/val tuple type array into a union of the "keys" -type ExtractTypeKeys< - Arr extends unknown[] = [], - Acc extends unknown[] = [], -> = Arr extends [] - ? Acc - : // eslint-disable-next-line @typescript-eslint/no-explicit-any - Arr extends [[infer Head, any], ...infer Tail] - ? Head | ExtractTypeKeys - : never; - -export const fetchPlexPath = ( - apiClient: ApiClient, - serverName: string, - path: string, -) => { - return async () => { - return apiClient - .getPlexPath({ - queries: { - name: serverName, - path, - }, - }) - .then((r) => r as T); - }; -}; - -export const usePlex = < - T extends ExtractTypeKeys, - OutType = FindChild0, ->( - serverName: string, - path: string, - enabled: boolean = true, -) => - useApiQuery({ - queryKey: ['plex', serverName, path], - queryFn: (apiClient) => - fetchPlexPath(apiClient, serverName, path)(), - enabled, - }); - -export const usePlexLibraries = (serverName: string, enabled: boolean = true) => - usePlex<'/library/sections'>(serverName, '/library/sections', enabled); - -declare const plexQueryArgsSymbol: unique symbol; - -type PlexQueryArgs = { - serverName: string; - path: string; - enabled: boolean; - [plexQueryArgsSymbol]?: T; -}; - -export const plexQueryOptions = ( - apiClient: ApiClient, - serverName: string, - path: string, - enabled: boolean = true, -) => ({ - queryKey: ['plex', serverName, path], - queryFn: fetchPlexPath(apiClient, serverName, path), - enabled: enabled && serverName.length > 0 && path.length > 0, -}); - -export const usePlexTyped = ( - serverName: string, - path: string, - enabled: boolean = true, -) => { - const apiClient = useTunarrApi(); - return useQuery(plexQueryOptions(apiClient, serverName, path, enabled)); -}; - -/** - * Like {@link usePlexTyped} but accepts two queries that each return - * a well-typed Plex object - */ -export const usePlexTyped2 = ( - args: [PlexQueryArgs, PlexQueryArgs], -) => { - const apiClient = useTunarrApi(); - return useQueries({ - queries: args.map((query) => ({ - queryKey: ['plex', query.serverName, query.path], - queryFn: fetchPlexPath<(typeof query)[typeof plexQueryArgsSymbol]>( - apiClient, - query.serverName, - query.path, - ), - enabled: query.enabled, - })), - combine: ([firstResult, secondResult]) => { - return { - first: firstResult.data as T | undefined, - second: secondResult.data as U | undefined, - isPending: firstResult.isPending || secondResult.isPending, - error: firstResult.error || secondResult.error, - }; - }, - }); -}; - -export const usePlexServerStatus = (server: PlexServerSettings) => { - return useQuery({ - queryKey: ['plex-servers', server.id, 'status-local'], - queryFn: async () => { - try { - await axios.get(`${server.uri}`, { - headers: { - ...DefaultPlexHeaders, - 'X-Plex-Token': server.accessToken, - }, - timeout: 30 * 1000, - }); - return true; - } catch (e) { - return false; - } - }, - }); -}; - -export const usePlexFilters = (serverName: string, plexKey: string) => { - const apiClient = useTunarrApi(); - const key = `/library/sections/${plexKey}/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0`; - const query = useQuery({ - ...plexQueryOptions( - apiClient, - serverName, - key, - serverName.length > 0 && plexKey.length > 0, - ), - staleTime: 1000 * 60 * 60 * 60, - }); - - useEffect(() => { - if (query.data) { - setPlexMetadataFilters(serverName, plexKey, query.data); - } - }, [serverName, plexKey, query.data]); - - return { - isLoading: query.isLoading, - error: query.error, - data: useStore(({ plexMetadata }) => { - const server = plexMetadata.libraryFilters[serverName]; - if (server) { - return server[plexKey]?.Meta; - } - }), - }; -}; - -// Like usePlexFilters, but uses the selected server and library from -// local state. -export const useSelectedLibraryPlexFilters = () => { - const selectedServer = useStore((s) => s.currentServer); - const selectedLibrary = useStore((s) => - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ); - return usePlexFilters( - selectedServer?.name ?? '', - selectedLibrary?.library.key ?? '', - ); -}; - -export const usePlexTags = (key: string) => { - const apiClient = useTunarrApi(); - const selectedServer = useStore((s) => s.currentServer); - const selectedLibrary = useStore((s) => - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ); - const path = selectedLibrary - ? `/library/sections/${selectedLibrary.library.key}/${key}` - : ''; - - return useQuery({ - ...plexQueryOptions(apiClient, selectedServer?.name ?? '', path), - }); -}; - -export type EnrichedPlexMedia = PlexTerminalMedia & { - // This is the Plex server name that the info was retrieved from - serverName: string; - // If we found an existing reference to this item on the server, we add it here - id?: string; - showId?: string; - seasonId?: string; -}; - -function plexItemExternalId(serverName: string, media: PlexTerminalMedia) { - return `plex|${serverName}|${media.key}`; -} - -export const enumeratePlexItem = ( - apiClient: ApiClient, - serverName: string, - initialItem: PlexMedia | PlexLibrarySection, -): (() => Promise) => { - const fetchPlexPathFunc = (path: string) => - fetchPlexPath(apiClient, serverName, path)(); - - async function loopInner( - item: PlexMedia | PlexLibrarySection, - ): Promise { - if (isTerminalItem(item)) { - return [{ ...item, serverName }]; - } else { - const path = isPlexDirectory(item) - ? `/library/sections/${item.key}/all` - : item.key; - - return fetchPlexPathFunc< - PlexLibraryListing | PlexSeasonView | PlexEpisodeView - >(path) - .then(async (result) => { - return sequentialPromises(result.Metadata, loopInner); - }) - .then((allResults) => flattenDeep(allResults)); - } - } - - return async function () { - const res = await loopInner(initialItem); - const externalIds = res.map((m) => plexItemExternalId(serverName, m)); - - // This is best effort - if the user saves these IDs later, the upsert - // logic should figure out what is new/existing - try { - const existingIdsByExternalId = - await apiClient.batchGetProgramsByExternalIds({ externalIds }); - return map(res, (media) => { - const existing = - existingIdsByExternalId[plexItemExternalId(serverName, media)]; - return { - ...media, - id: existing?.id, - showId: existing?.showId, - seasonId: existing?.seasonId, - }; - }); - } catch (e) { - console.error('Unable to retrieve IDs in batch', e); - } - - return res; - }; -}; - -export const usePlexSearch = () => {}; diff --git a/web/src/pages/channels/ProgrammingSelectorPage.tsx b/web/src/pages/channels/ProgrammingSelectorPage.tsx index 14e7ab1de..5ba736b7e 100644 --- a/web/src/pages/channels/ProgrammingSelectorPage.tsx +++ b/web/src/pages/channels/ProgrammingSelectorPage.tsx @@ -1,13 +1,25 @@ +import SelectedProgrammingList from '@/components/channel_config/SelectedProgrammingList.tsx'; import Breadcrumbs from '../../components/Breadcrumbs.tsx'; import PaddedPaper from '../../components/base/PaddedPaper.tsx'; import ProgrammingSelector from '../../components/channel_config/ProgrammingSelector.tsx'; +import { useNavigate } from 'react-router-dom'; +import { addMediaToCurrentChannel } from '@/store/channelEditor/actions.ts'; +import useStore from '@/store/index.ts'; export default function ProgrammingSelectorPage() { + const selectedLibrary = useStore((s) => s.currentLibrary); + console.log(selectedLibrary); + const navigate = useNavigate(); return ( <> + navigate(-1)} + selectAllEnabled={selectedLibrary?.type === 'plex'} + /> ); diff --git a/web/src/pages/settings/PlexSettingsPage.tsx b/web/src/pages/settings/PlexSettingsPage.tsx index 0eb9986a4..c29d8abc5 100644 --- a/web/src/pages/settings/PlexSettingsPage.tsx +++ b/web/src/pages/settings/PlexSettingsPage.tsx @@ -60,7 +60,7 @@ import { TypedController, } from '../../components/util/TypedController.tsx'; import { toggle } from '../../helpers/util.ts'; -import { usePlexServerStatus } from '../../hooks/plexHooks.ts'; +import { usePlexServerStatus } from '../../hooks/plex/usePlexServerStatus.ts'; import { usePlexServerSettings, usePlexStreamSettings, diff --git a/web/src/store/channelEditor/actions.ts b/web/src/store/channelEditor/actions.ts index 77bc76723..9992d9816 100644 --- a/web/src/store/channelEditor/actions.ts +++ b/web/src/store/channelEditor/actions.ts @@ -33,7 +33,7 @@ import { unwrapNil, zipWithIndex, } from '../../helpers/util.ts'; -import { EnrichedPlexMedia } from '../../hooks/plexHooks.ts'; +import { EnrichedPlexMedia } from '../../hooks/plex/plexHookUtil.ts'; import { AddedMedia, UIChannelProgram, UIIndex } from '../../types/index.ts'; import useStore from '../index.ts'; import { ChannelEditorState, initialChannelEditorState } from './store.ts'; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index dc4c28790..528be21b1 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -15,7 +15,7 @@ import { } from '@zodios/core/lib/zodios.types'; import { LoaderFunctionArgs } from 'react-router-dom'; import { type ApiClient } from '../external/api.ts'; -import { EnrichedPlexMedia } from '../hooks/plexHooks.ts'; +import { EnrichedPlexMedia } from '../hooks/plex/plexHookUtil.ts'; // A program that may or may not exist in the DB yet export type EphemeralProgram = Omit; @@ -123,4 +123,7 @@ export type AddedPlexMedia = { media: EnrichedPlexMedia; }; +/** + * Media type going from "selected" -> "added to entity". + */ export type AddedMedia = AddedPlexMedia | AddedCustomShowProgram; diff --git a/web/src/types/util.ts b/web/src/types/util.ts new file mode 100644 index 000000000..143b8671b --- /dev/null +++ b/web/src/types/util.ts @@ -0,0 +1,24 @@ +// Turns a key/val tuple type array into a union of the "keys" +export type ExtractTypeKeys< + Arr extends unknown[] = [], + Acc extends unknown[] = [], +> = Arr extends [] + ? Acc + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + Arr extends [[infer Head, any], ...infer Tail] + ? Head | ExtractTypeKeys + : never; + +export type FindChild = Arr extends [ + [infer Head, infer Child], + ...infer Tail, +] + ? Head extends Target + ? Child + : FindChild + : never; + +// TODO: Move these to shared types library +export type Maybe = T | undefined; +export type Nullable = T | null; +export type Nilable = T | undefined | null; diff --git a/web/tsconfig.json b/web/tsconfig.json index 374442235..7171f80f6 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -22,7 +22,12 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": [ + "./src/*" + ] + } }, "files": [ "vite.config.ts", diff --git a/web/vite.config.ts b/web/vite.config.ts index 73e72d78f..73c9dcfd3 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,5 +1,6 @@ import react from '@vitejs/plugin-react-swc'; import { defineConfig } from 'vite'; +import path from 'node:path'; // https://vitejs.dev/config/ export default defineConfig({ @@ -8,6 +9,11 @@ export default defineConfig({ sourcemap: true, }, base: '/web', + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, server: { host: process.env['TUNARR_BIND_ADDR'] ?? 'localhost', },