From ae1e1e0064d91b72d3b71289479e42b1261bbede Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Wed, 13 Mar 2024 15:38:17 -0400 Subject: [PATCH] Custom Show Programming Picker - next steps --- .../channel_config/AddSelectedMediaButton.tsx | 8 +- .../CustomShowProgrammingSelector.tsx | 145 +++++++++++- .../channel_config/PlexDirectoryListItem.tsx | 4 +- .../channel_config/PlexGridItem.tsx | 16 +- .../channel_config/PlexListItem.tsx | 14 +- .../PlexProgrammingSelector.tsx | 20 +- .../channel_config/ProgrammingSelector.tsx | 33 +-- .../SelectedProgrammingList.tsx | 93 +++++--- web/src/helpers/util.ts | 209 +++++++++++++++++- web/src/hooks/useCustomShows.ts | 17 ++ web/src/pages/guide/GuidePage.tsx | 144 ++++-------- web/src/store/programmingSelector/actions.ts | 67 ++++-- web/src/store/programmingSelector/store.ts | 15 +- web/src/store/themeEditor/actions.ts | 5 +- web/src/store/themeEditor/store.ts | 3 +- web/src/types/index.ts | 2 + 16 files changed, 607 insertions(+), 188 deletions(-) diff --git a/web/src/components/channel_config/AddSelectedMediaButton.tsx b/web/src/components/channel_config/AddSelectedMediaButton.tsx index c8358f466..704b91877 100644 --- a/web/src/components/channel_config/AddSelectedMediaButton.tsx +++ b/web/src/components/channel_config/AddSelectedMediaButton.tsx @@ -1,10 +1,11 @@ import { Tooltip } from '@mui/material'; import Button, { ButtonProps } from '@mui/material/Button'; -import { flattenDeep } from 'lodash-es'; +import { filter, flattenDeep } from 'lodash-es'; import { sequentialPromises } from '../../helpers/util.ts'; import { EnrichedPlexMedia, enumeratePlexItem } from '../../hooks/plexHooks.ts'; import useStore from '../../store/index.ts'; import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts'; +import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; type Props = { onAdd: (items: EnrichedPlexMedia[]) => void; @@ -17,7 +18,10 @@ export default function AddSelectedMediaButton({ ...rest }: Props) { const knownMedia = useStore((s) => s.knownMediaByServer); - const selectedMedia = useStore((s) => s.selectedMedia); + // TODO support custom shows + const selectedMedia = useStore((s) => + filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), + ); const addSelectedItems = () => { sequentialPromises(selectedMedia, (selected) => { diff --git a/web/src/components/channel_config/CustomShowProgrammingSelector.tsx b/web/src/components/channel_config/CustomShowProgrammingSelector.tsx index bb3ca6de9..196318995 100644 --- a/web/src/components/channel_config/CustomShowProgrammingSelector.tsx +++ b/web/src/components/channel_config/CustomShowProgrammingSelector.tsx @@ -1,3 +1,146 @@ +import { + Box, + Button, + Divider, + LinearProgress, + List, + ListItem, + ListItemText, +} from '@mui/material'; +import { ContentProgram, isContentProgram } from '@tunarr/types'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { chain, isEmpty, isNil, property } from 'lodash-es'; +import { MouseEvent, useCallback, useState } from 'react'; +import { useIntersectionObserver } from 'usehooks-ts'; +import { forProgramType } from '../../helpers/util'; +import { useCustomShow } from '../../hooks/useCustomShows'; +import useStore from '../../store'; +import { addSelectedMedia } from '../../store/programmingSelector/actions'; + +dayjs.extend(duration); + export function CustomShowProgrammingSelector() { - return
; + const selectedCustomShow = useStore((s) => + s.currentLibrary?.type === 'custom-show' ? s.currentLibrary : null, + ); + const viewType = useStore((state) => state.theme.programmingSelectorView); + const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); + + const [showResult, programsResult] = useCustomShow( + /*id=*/ selectedCustomShow?.library.id ?? '', + /*enabled=*/ !isNil(selectedCustomShow), + /*includePrograms=*/ true, + ); + + const isLoading = showResult.isLoading || programsResult.isLoading; + + const formattedTitle = useCallback( + forProgramType({ + content: (p) => p.title, + }), + [], + ); + + const formattedEpisodeTitle = useCallback( + forProgramType({ + custom: (p) => p.program?.episodeTitle ?? '', + }), + [], + ); + + const { ref } = useIntersectionObserver({ + onChange: (_, entry) => { + if (entry.isIntersecting && scrollParams.limit < scrollParams.max) { + setScrollParams(({ limit: prevLimit, max }) => ({ + max, + limit: prevLimit + 10, + })); + } + }, + threshold: 0.5, + }); + + const handleItem = useCallback( + (e: MouseEvent, item: ContentProgram) => { + e.stopPropagation(); + if (selectedCustomShow) { + addSelectedMedia({ + type: 'custom-show', + customShowId: selectedCustomShow.library.id, + program: item, + }); + } + }, + [], + ); + + const renderListItems = () => { + if ( + showResult.data && + programsResult.data && + programsResult.data.length > 0 + ) { + return chain(programsResult.data) + .filter(isContentProgram) + .filter(property('persisted')) + .map((program) => { + let title = formattedTitle(program); + let epTitle = formattedEpisodeTitle(program); + if (!isEmpty(epTitle)) { + title += ` - ${epTitle}`; + } + + return ( + + + + + ); + }) + .compact() + .value(); + } + + return null; + }; + + return ( + + + + {renderListItems()} +
+
+ + +
+ ); } diff --git a/web/src/components/channel_config/PlexDirectoryListItem.tsx b/web/src/components/channel_config/PlexDirectoryListItem.tsx index 6f55b78ac..32b197cb1 100644 --- a/web/src/components/channel_config/PlexDirectoryListItem.tsx +++ b/web/src/components/channel_config/PlexDirectoryListItem.tsx @@ -24,7 +24,7 @@ import { usePlexTyped2 } from '../../hooks/plexHooks.ts'; import useStore from '../../store/index.ts'; import { addKnownMediaForServer, - addSelectedMedia, + addPlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { PlexListItem } from './PlexListItem.tsx'; @@ -102,7 +102,7 @@ export function PlexDirectoryListItem(props: { const addItems = useCallback( (e: MouseEvent) => { e.stopPropagation(); - addSelectedMedia(server.name, [item]); + addPlexSelectedMedia(server.name, [item]); }, [item, server.name], ); diff --git a/web/src/components/channel_config/PlexGridItem.tsx b/web/src/components/channel_config/PlexGridItem.tsx index ec7af47ca..03c57e0c1 100644 --- a/web/src/components/channel_config/PlexGridItem.tsx +++ b/web/src/components/channel_config/PlexGridItem.tsx @@ -20,15 +20,17 @@ import { isPlexCollection, isTerminalItem, } from '@tunarr/types/plex'; +import { filter } from 'lodash-es'; import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; import { prettyItemDuration } from '../../helpers/util.ts'; import { usePlexTyped } from '../../hooks/plexHooks.ts'; import useStore from '../../store/index.ts'; import { addKnownMediaForServer, - addSelectedMedia, - removeSelectedMedia, + addPlexSelectedMedia, + removePlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; +import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; export interface PlexGridItemProps { item: T; @@ -51,8 +53,10 @@ export function PlexGridItem(props: PlexGridItemProps) { hasChildren && open, ); const selectedServer = useStore((s) => s.currentServer); - const selectedMedia = useStore((s) => s.selectedMedia); - const selectedMediaIds = selectedMedia.map((item) => item['guid']); + const selectedMedia = useStore((s) => + filter(s.selectedMedia, (p): p is PlexSelectedMedia => p.type === 'plex'), + ); + const selectedMediaIds = selectedMedia.map((item) => item.guid); const handleClick = () => { setOpen(!open); @@ -69,9 +73,9 @@ export function PlexGridItem(props: PlexGridItemProps) { e.stopPropagation(); if (selectedMediaIds.includes(item.guid)) { - removeSelectedMedia(selectedServer!.name, [item.guid]); + removePlexSelectedMedia(selectedServer!.name, [item.guid]); } else { - addSelectedMedia(selectedServer!.name, [item]); + addPlexSelectedMedia(selectedServer!.name, [item]); } }, [item, selectedServer, selectedMediaIds], diff --git a/web/src/components/channel_config/PlexListItem.tsx b/web/src/components/channel_config/PlexListItem.tsx index 64059c56b..4d1c05bd4 100644 --- a/web/src/components/channel_config/PlexListItem.tsx +++ b/web/src/components/channel_config/PlexListItem.tsx @@ -18,15 +18,17 @@ import { isPlexShow, isTerminalItem, } from '@tunarr/types/plex'; +import { filter } from 'lodash-es'; import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; import { prettyItemDuration } from '../../helpers/util.ts'; import { usePlexTyped } from '../../hooks/plexHooks.ts'; import useStore from '../../store/index.ts'; import { addKnownMediaForServer, - addSelectedMedia, - removeSelectedMedia, + addPlexSelectedMedia, + removePlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; +import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; export interface PlexListItemProps { item: T; @@ -48,7 +50,9 @@ export function PlexListItem(props: PlexListItemProps) { hasChildren && open, ); const selectedServer = useStore((s) => s.currentServer); - const selectedMedia = useStore((s) => s.selectedMedia); + const selectedMedia = useStore((s) => + filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), + ); const selectedMediaIds = selectedMedia.map((item) => item['guid']); const handleClick = () => { @@ -66,9 +70,9 @@ export function PlexListItem(props: PlexListItemProps) { e.stopPropagation(); if (selectedMediaIds.includes(item.guid)) { - removeSelectedMedia(selectedServer!.name, [item.guid]); + removePlexSelectedMedia(selectedServer!.name, [item.guid]); } else { - addSelectedMedia(selectedServer!.name, [item]); + addPlexSelectedMedia(selectedServer!.name, [item]); } }, [item, selectedServer, selectedMediaIds], diff --git a/web/src/components/channel_config/PlexProgrammingSelector.tsx b/web/src/components/channel_config/PlexProgrammingSelector.tsx index 705d94b26..12a8db108 100644 --- a/web/src/components/channel_config/PlexProgrammingSelector.tsx +++ b/web/src/components/channel_config/PlexProgrammingSelector.tsx @@ -19,7 +19,6 @@ import { TextField, ToggleButton, ToggleButtonGroup, - Typography, } from '@mui/material'; import { DataTag, useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { @@ -34,27 +33,20 @@ import { Fragment, useCallback, useEffect, useState } from 'react'; import { useIntersectionObserver } from 'usehooks-ts'; import { toggle } from '../../helpers/util'; import { - EnrichedPlexMedia, fetchPlexPath, - usePlex, + usePlex } from '../../hooks/plexHooks'; import { usePlexServerSettings } from '../../hooks/settingsHooks'; import useDebouncedState from '../../hooks/useDebouncedState'; import useStore from '../../store'; import { addKnownMediaForServer } from '../../store/programmingSelector/actions'; import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions'; +import { ProgramSelectorViewType } from '../../types'; import ConnectPlex from '../settings/ConnectPlex'; import { PlexGridItem } from './PlexGridItem'; import { PlexListItem } from './PlexListItem'; -import SelectedProgrammingList from './SelectedProgrammingList'; -type ViewType = 'list' | 'grid'; - -type Props = { - onAddSelectedMedia: (items: EnrichedPlexMedia[]) => void; -}; - -export default function PlexProgrammingSelector({ onAddSelectedMedia }: Props) { +export default function PlexProgrammingSelector() { const { data: plexServers } = usePlexServerSettings(); const selectedServer = useStore((s) => s.currentServer); const selectedLibrary = useStore((s) => @@ -78,13 +70,13 @@ export default function PlexProgrammingSelector({ onAddSelectedMedia }: Props) { setSearchBarOpen(false); }, [setSearch]); - const setViewType = (view: ViewType) => { + const setViewType = (view: ProgramSelectorViewType) => { setProgrammingSelectorViewState(view); }; const handleFormat = ( _event: React.MouseEvent, - newFormats: ViewType, + newFormats: ProgramSelectorViewType, ) => { setViewType(newFormats); }; @@ -341,8 +333,6 @@ export default function PlexProgrammingSelector({ onAddSelectedMedia }: Props) { - Selected Items - )} diff --git a/web/src/components/channel_config/ProgrammingSelector.tsx b/web/src/components/channel_config/ProgrammingSelector.tsx index 12e96a60b..5a7762e69 100644 --- a/web/src/components/channel_config/ProgrammingSelector.tsx +++ b/web/src/components/channel_config/ProgrammingSelector.tsx @@ -4,6 +4,7 @@ import { MenuItem, Select, Stack, + Typography, } from '@mui/material'; import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex'; import { find, isEmpty, isNil, isUndefined } from 'lodash-es'; @@ -19,6 +20,7 @@ import { } from '../../store/programmingSelector/actions.ts'; import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx'; import PlexProgrammingSelector from './PlexProgrammingSelector.tsx'; +import SelectedProgrammingList from './SelectedProgrammingList.tsx'; export interface PlexListItemProps { item: T; @@ -89,11 +91,7 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { }); useEffect(() => { - if ( - mediaSource === 'custom-shows' && - customShows && - customShows.length > 0 - ) { + if (mediaSource === 'custom-shows' && customShows.length > 0) { setProgrammingListLibrary({ type: 'custom-show', library: customShows[0], @@ -102,13 +100,13 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { }, [mediaSource, customShows]); const onMediaSourceChange = useCallback( - (mediaSource: string) => { - if (mediaSource === 'custom-shows') { + (newMediaSource: string) => { + if (newMediaSource === 'custom-shows') { // Not dealing with a server setProgrammingListingServer(undefined); - setMediaSource(mediaSource); + setMediaSource(newMediaSource); } else { - const server = find(plexServers, { name: mediaSource }); + const server = find(plexServers, { name: newMediaSource }); if (server) { setProgrammingListingServer(server); setMediaSource(server.name); @@ -120,8 +118,13 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { const onLibraryChange = useCallback( (libraryUuid: string) => { - // TODO support loading custom shows - if (selectedServer) { + if (mediaSource === 'custom-shows') { + console.log('hello', customShows); + const library = find(customShows, { id: libraryUuid }); + if (library) { + setProgrammingListLibrary({ type: 'custom-show', library }); + } + } else if (selectedServer) { const known = knownMedia[selectedServer.name] ?? {}; const library = known[libraryUuid]; if (library && isPlexDirectory(library)) { @@ -129,16 +132,14 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { } } }, - [knownMedia, selectedServer], + [mediaSource, knownMedia, selectedServer], ); const renderMediaSourcePrograms = () => { if (selectedLibrary?.type === 'custom-show') { return ; } else if (selectedLibrary?.type === 'plex') { - return ( - - ); + return ; } return null; @@ -212,6 +213,8 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { )} {renderMediaSourcePrograms()} + Selected Items + ); } diff --git a/web/src/components/channel_config/SelectedProgrammingList.tsx b/web/src/components/channel_config/SelectedProgrammingList.tsx index 22f6f08cb..71968d2a7 100644 --- a/web/src/components/channel_config/SelectedProgrammingList.tsx +++ b/web/src/components/channel_config/SelectedProgrammingList.tsx @@ -12,15 +12,21 @@ import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import { isPlexDirectory, isPlexSeason, isPlexShow } from '@tunarr/types/plex'; +import { chain, first, groupBy, mapValues } from 'lodash-es'; import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import { + forProgramType, + forSelectedMediaType, + unwrapNil, +} from '../../helpers/util.ts'; import { EnrichedPlexMedia } from '../../hooks/plexHooks.ts'; +import { useCustomShows } from '../../hooks/useCustomShows.ts'; import useStore from '../../store/index.ts'; import { clearSelectedMedia, removeSelectedMedia, } from '../../store/programmingSelector/actions.ts'; -import { SelectedMedia } from '../../store/programmingSelector/store.ts'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; type Props = { @@ -28,44 +34,79 @@ type Props = { }; export default function SelectedProgrammingList({ onAddSelectedMedia }: Props) { + const { data: customShows } = useCustomShows(); const knownMedia = useStore((s) => s.knownMediaByServer); const selectedMedia = useStore((s) => s.selectedMedia); const darkMode = useStore((state) => state.theme.darkMode); const navigate = useNavigate(); - const removeSelectedItem = useCallback((selectedMedia: SelectedMedia) => { - removeSelectedMedia(selectedMedia.server, [selectedMedia.guid]); - }, []); + const customShowById = mapValues( + mapValues(groupBy(customShows, 'id'), first), + unwrapNil, + ); const removeAllItems = useCallback(() => { clearSelectedMedia(); }, []); + const formattedTitle = useCallback( + forProgramType({ + content: (p) => p.title, + }), + [], + ); + + const formattedEpisodeTitle = useCallback( + forProgramType({ + custom: (p) => p.program?.episodeTitle ?? '', + }), + [], + ); + const renderSelectedItems = () => { - const items = selectedMedia.map((selected) => { - const media = knownMedia[selected.server][selected.guid]; - console.log(media); + const items = chain(selectedMedia) + .map( + forSelectedMediaType({ + plex: (selected) => { + const media = knownMedia[selected.server][selected.guid]; + + let title: string = media.title; + if (isPlexDirectory(media)) { + title = `Library - ${media.title}`; + } else if (isPlexShow(media)) { + title = `${media.title} (${media.childCount} season(s), ${media.leafCount} total episodes)`; + } else if (isPlexSeason(media)) { + title = `${media.parentTitle} - ${media.title} (${media.leafCount} episodes)`; + } - let title: string = media.title; - if (isPlexDirectory(media)) { - title = `Library - ${media.title}`; - } else if (isPlexShow(media)) { - title = `${media.title} (${media.childCount} season(s), ${media.leafCount} total episodes)`; - } else if (isPlexSeason(media)) { - title = `${media.parentTitle} - ${media.title} (${media.leafCount} episodes)`; - } + return ( + + + + removeSelectedMedia([selected])}> + + + + + ); + }, + 'custom-show': (selected) => { + const customShow = customShowById[selected.customShowId]; + return ( + customShow && ( + + Custom Show {customShow.name} -{' '} + {formattedTitle(selected.program)}{' '} + {formattedEpisodeTitle(selected.program)} + + ) + ); + }, + }), + ) + .compact() + .value(); - return ( - - - - removeSelectedItem(selected)}> - - - - - ); - }); return {items}; }; diff --git a/web/src/helpers/util.ts b/web/src/helpers/util.ts index 96b0f7bad..5331ae4e0 100644 --- a/web/src/helpers/util.ts +++ b/web/src/helpers/util.ts @@ -1,8 +1,16 @@ import { Theme } from '@mui/material'; -import { ChannelProgram, FlexProgram, Resolution } from '@tunarr/types'; +import { + ChannelProgram, + FlexProgram, + Resolution, + TvGuideProgram, +} from '@tunarr/types'; +import { PlexMedia } from '@tunarr/types/plex'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; -import { isNumber, range, zipWith } from 'lodash-es'; +import { isFunction, isNumber, range, reduce, zipWith } from 'lodash-es'; +import { SelectedMedia } from '../store/programmingSelector/store'; +import { UIChannelProgram } from '../types'; dayjs.extend(duration); @@ -144,3 +152,200 @@ export const numericFormChangeHandler = ( cb(handleNumericFormValue(e.target.value, float)); }; }; + +// Generates a mapping of discriminator to the concrete tyhpe +type GenSubtypeMapping = { + [X in T['type']]: Extract; +}; + +type GenGroupedSubtypeMapping = { + [X in T['type']]: Extract[]; +}; + +type PerTypeCallback = { + [X in Union['type']]?: ((m: GenSubtypeMapping[X]) => Value) | Value; +} & { + default?: ((m: Union) => Value) | Value; +}; + +const applyOrValue = ( + f: ((m: X) => T) | T, + arg: X, +) => (isFunction(f) ? f(arg) : f); + +export const forSelectedMediaType = ( + choices: PerTypeCallback, +): ((m: SelectedMedia) => T | null) => { + // Unfortunately we still have to enumerate the types here + // in order to get proper type guarding + return (m: SelectedMedia) => { + if (m.type === 'custom-show' && choices['custom-show']) { + return applyOrValue(choices['custom-show'], m); + } else if (m.type === 'plex' && choices['plex']) { + return applyOrValue(choices['plex'], m); + } else if (choices.default) { + return applyOrValue(choices['default'], m); + } + + return null; + }; +}; + +// Produces a Record for each 'type' of SelectedMedia where the values +// are the properly downcasted subtypes +export function groupSelectedMedia( + media: SelectedMedia[], +): Partial> { + return reduce( + media, + (acc, m) => { + const curr = acc[m.type] ?? []; + return { + ...acc, + [m.type]: [...curr, m], + }; + }, + {} as Partial>, + ); +} + +export const forProgramType = ( + choices: PerTypeCallback, +) => { + return (m: ChannelProgram) => { + switch (m.type) { + case 'content': + if (choices.content) { + return applyOrValue(choices.content, m); + } + break; + case 'custom': + if (choices.custom) { + return applyOrValue(choices.custom, m); + } + break; + case 'redirect': + if (choices.redirect) { + return applyOrValue(choices.redirect, m); + } + break; + case 'flex': + if (choices.flex) { + return applyOrValue(choices.flex, m); + } + break; + } + + // If we made it this far try to do the default + if (choices.default) { + return applyOrValue(choices.default, m); + } + + return null; + }; +}; + +export const forUIProgramType = ( + choices: PerTypeCallback, +) => { + return (m: UIChannelProgram) => { + switch (m.type) { + case 'content': + if (choices.content) { + return applyOrValue(choices.content, m); + } + break; + case 'custom': + if (choices.custom) { + return applyOrValue(choices.custom, m); + } + break; + case 'redirect': + if (choices.redirect) { + return applyOrValue(choices.redirect, m); + } + break; + case 'flex': + if (choices.flex) { + return applyOrValue(choices.flex, m); + } + break; + } + + // If we made it this far try to do the default + if (choices.default) { + return applyOrValue(choices.default, m); + } + + return null; + }; +}; + +// Unclear if we can generalize this since we need to know we +// are dealing with a type that has subclasses, otherwise +// PerTypeCallback will have nevers +export const forTvGuideProgram = ( + choices: PerTypeCallback, +) => { + return (m: TvGuideProgram) => { + switch (m.type) { + case 'content': + if (choices.content) { + return applyOrValue(choices.content, m); + } + break; + case 'custom': + if (choices.custom) { + return applyOrValue(choices.custom, m); + } + break; + case 'redirect': + if (choices.redirect) { + return applyOrValue(choices.redirect, m); + } + break; + case 'flex': + if (choices.flex) { + return applyOrValue(choices.flex, m); + } + break; + } + + // If we made it this far try to do the default + if (choices.default) { + return applyOrValue(choices.default, m); + } + + return null; + }; +}; + +export const forPlexMedia = (choices: PerTypeCallback) => { + return (m: PlexMedia) => { + switch (m.type) { + case 'movie': + if (choices.movie) return applyOrValue(choices.movie, m); + break; + case 'show': + if (choices.show) return applyOrValue(choices.show, m); + break; + case 'season': + if (choices.season) return applyOrValue(choices.season, m); + break; + case 'episode': + if (choices.episode) return applyOrValue(choices.episode, m); + break; + case 'collection': + if (choices.collection) return applyOrValue(choices.collection, m); + break; + } + + if (choices.default) { + return applyOrValue(choices.default, m); + } + + return null; + }; +}; + +export const unwrapNil = (x: T | null | undefined) => x!; diff --git a/web/src/hooks/useCustomShows.ts b/web/src/hooks/useCustomShows.ts index 59c35f357..a1e635b9b 100644 --- a/web/src/hooks/useCustomShows.ts +++ b/web/src/hooks/useCustomShows.ts @@ -1,6 +1,7 @@ import { DataTag, DefinedInitialDataOptions, + useQueries, useQuery, } from '@tanstack/react-query'; import { CustomShow } from '@tunarr/types'; @@ -49,3 +50,19 @@ export const customShowProgramsQuery = (id: string) => ({ >, queryFn: () => apiClient.getCustomShowPrograms({ params: { id } }), }); + +export const useCustomShow = ( + id: string, + enabled: boolean, + includePrograms: boolean, +) => { + return useQueries({ + queries: [ + { ...customShowQuery(id), enabled }, + { + ...customShowProgramsQuery(id), + enabled: enabled && includePrograms, + }, + ], + }); +}; diff --git a/web/src/pages/guide/GuidePage.tsx b/web/src/pages/guide/GuidePage.tsx index 13615894f..3cf906546 100644 --- a/web/src/pages/guide/GuidePage.tsx +++ b/web/src/pages/guide/GuidePage.tsx @@ -30,7 +30,11 @@ import { isEmpty, round } from 'lodash-es'; import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { useInterval } from 'usehooks-ts'; import PaddedPaper from '../../components/base/PaddedPaper.tsx'; -import { alternateColors, prettyItemDuration } from '../../helpers/util.ts'; +import { + alternateColors, + forTvGuideProgram, + prettyItemDuration, +} from '../../helpers/util.ts'; import { prefetchAllTvGuides, useAllTvGuides } from '../../hooks/useTvGuide.ts'; import useStore from '../../store/index.ts'; import { setGuideDurationState } from '../../store/themeEditor/actions.ts'; @@ -268,37 +272,18 @@ export default function GuidePage() { index: number, lineup: TvGuideProgram[], ) => { - let title: string; - switch (program.type) { - case 'custom': - title = program.program?.title ?? 'Custom Program'; - break; - case 'content': - title = program.title; - break; - case 'redirect': - title = `Redirect to Channel ${program.channel}`; - break; - case 'flex': - title = 'Flex'; - break; - } - - let episodeTitle: string | undefined; - switch (program.type) { - case 'custom': - episodeTitle = program.program?.episodeTitle ?? ''; - break; - case 'content': - episodeTitle = program.episodeTitle; - break; - case 'redirect': - episodeTitle = ''; - break; - case 'flex': - episodeTitle = ''; - break; - } + const title = forTvGuideProgram({ + content: (p) => p.title, + custom: (p) => p.program?.title ?? 'Custom Program', + redirect: (p) => `Redirect to Channel ${p.channel}`, + flex: 'Flex', + })(program); + + const episodeTitle = forTvGuideProgram({ + custom: (p) => p.program?.episodeTitle ?? '', + content: (p) => p.episodeTitle, + default: '', + })(program); const key = `${title}_${program.start}_${program.stop}`; const programStart = dayjs(program.start); @@ -423,74 +408,41 @@ export default function GuidePage() { ); }; + const formattedTitle = useCallback( + forTvGuideProgram({ + content: (p) => p.title, + custom: (p) => p.program?.title ?? 'Custom Program', + redirect: (p) => `Redirect to Channel ${p.channel}`, + flex: 'Flex', + }), + [], + ); + + const formattedEpisodeTitle = useCallback( + forTvGuideProgram({ + custom: (p) => p.program?.episodeTitle ?? '', + content: (p) => p.episodeTitle, + default: '', + }), + [], + ); + const renderProgramModal = (program: TvGuideProgram | undefined) => { if (!program) { return; } - let title: string; - switch (program.type) { - case 'custom': - title = program.program?.title ?? 'Custom Program'; - break; - case 'content': - title = program.title; - break; - case 'redirect': - title = `Redirect to Channel ${program.channel}`; - break; - case 'flex': - title = 'Flex'; - break; - } - - let episodeTitle: string | undefined; - switch (program.type) { - case 'custom': - episodeTitle = program.program?.episodeTitle ?? ''; - break; - case 'content': - episodeTitle = program.episodeTitle; - break; - case 'redirect': - episodeTitle = ''; - break; - case 'flex': - episodeTitle = ''; - break; - } + const rating = forTvGuideProgram({ + custom: (p) => p.program?.rating ?? '', + content: (p) => p.rating, + default: '', + })(program); - let rating: string | undefined; - switch (program.type) { - case 'custom': - rating = program.program?.rating ?? ''; - break; - case 'content': - rating = program.rating; - break; - case 'redirect': - rating = ''; - break; - case 'flex': - rating = ''; - break; - } - - let summary: string | undefined; - switch (program.type) { - case 'custom': - summary = program.program?.summary ?? ''; - break; - case 'content': - summary = program.summary; - break; - case 'redirect': - summary = ''; - break; - case 'flex': - summary = ''; - break; - } + const summary = forTvGuideProgram({ + custom: (p) => p.program?.summary ?? '', + content: (p) => p.summary, + default: '', + })(program); return ( - {title} + {formattedTitle(program)} - {episodeTitle} + {formattedEpisodeTitle(program)} {program.type === 'content' ? ( <> diff --git a/web/src/store/programmingSelector/actions.ts b/web/src/store/programmingSelector/actions.ts index 6578341d3..3ffdb26c5 100644 --- a/web/src/store/programmingSelector/actions.ts +++ b/web/src/store/programmingSelector/actions.ts @@ -5,8 +5,9 @@ import { isPlexDirectory, isTerminalItem, } from '@tunarr/types/plex'; -import { map, reject } from 'lodash-es'; +import { map, reject, some } from 'lodash-es'; import useStore from '..'; +import { forSelectedMediaType, groupSelectedMedia } from '../../helpers/util'; import { SelectedLibrary, SelectedMedia } from './store'; export const setProgrammingListingServer = ( @@ -83,37 +84,77 @@ export const addKnownMediaForServer = ( return state; }); -export const addSelectedMedia = ( +export const addPlexSelectedMedia = ( serverName: string, media: (PlexLibrarySection | PlexMedia)[], ) => useStore.setState((state) => { - const newSelectedMedia = map( - media, - (m) => - ({ - server: serverName, - guid: isPlexDirectory(m) ? m.uuid : m.guid, - }) as SelectedMedia, - ); + const newSelectedMedia: SelectedMedia[] = map(media, (m) => ({ + type: 'plex', + server: serverName, + guid: isPlexDirectory(m) ? m.uuid : m.guid, + })); state.selectedMedia = [...state.selectedMedia, ...newSelectedMedia]; }); -export const addSelectedMediaById = (serverName: string, ids: string[]) => +export const addPlexSelectedMediaById = (serverName: string, ids: string[]) => useStore.setState((state) => { const newSelectedMedia: SelectedMedia[] = map(ids, (m) => ({ + type: 'plex', server: serverName, guid: m, })); state.selectedMedia = [...state.selectedMedia, ...newSelectedMedia]; }); -export const removeSelectedMedia = (serverName: string, guids: string[]) => +export const addSelectedMedia = (media: SelectedMedia | SelectedMedia[]) => + useStore.setState((state) => { + state.selectedMedia = state.selectedMedia.concat(media); + }); + +export const removeSelectedMedia = (media: SelectedMedia[]) => + useStore.setState((state) => { + const grouped = groupSelectedMedia(media); + const it = forSelectedMediaType({ + plex: (plex) => + some(grouped.plex, { server: plex.server, guid: plex.guid }), + 'custom-show': (cs) => + some(grouped['custom-show'], { + customShowId: cs.customShowId, + programId: cs.program.id, + }), + default: false, + }); + + state.selectedMedia = reject(state.selectedMedia, (sm) => it(sm)!); + }); + +export const removePlexSelectedMedia = (serverName: string, guids: string[]) => useStore.setState((state) => { const guidsSet = new Set([...guids]); state.selectedMedia = reject( state.selectedMedia, - (m) => m.server === serverName && guidsSet.has(m.guid), + (m) => + m.type === 'plex' && m.server === serverName && guidsSet.has(m.guid), + ); + }); + +export const removeCustomShowSelectedMedia = ( + csId: string, + programIds: string[], +) => + useStore.setState((state) => { + if (programIds.length === 0) { + return; + } + + const idsSet = new Set([...programIds]); + state.selectedMedia = reject( + state.selectedMedia, + (m) => + m.type === 'custom-show' && + m.customShowId === csId && + idsSet.has(m.program.id!), ); }); diff --git a/web/src/store/programmingSelector/store.ts b/web/src/store/programmingSelector/store.ts index 8723f6d39..f8e982fae 100644 --- a/web/src/store/programmingSelector/store.ts +++ b/web/src/store/programmingSelector/store.ts @@ -1,14 +1,23 @@ -import { CustomShow, PlexServerSettings } from '@tunarr/types'; +import { ContentProgram, CustomShow, PlexServerSettings } from '@tunarr/types'; import { PlexLibrarySection, PlexMedia } from '@tunarr/types/plex'; import { StateCreator } from 'zustand'; type ServerName = string; type PlexItemGuid = string; -export interface SelectedMedia { +export type PlexSelectedMedia = { + type: 'plex'; server: ServerName; guid: PlexItemGuid; -} +}; + +export type CustomShowSelectedMedia = { + type: 'custom-show'; + customShowId: string; + program: ContentProgram; +}; + +export type SelectedMedia = PlexSelectedMedia | CustomShowSelectedMedia; export type PlexLibrary = { type: 'plex'; diff --git a/web/src/store/themeEditor/actions.ts b/web/src/store/themeEditor/actions.ts index 395dbbbfb..8b0a8d9f6 100644 --- a/web/src/store/themeEditor/actions.ts +++ b/web/src/store/themeEditor/actions.ts @@ -1,3 +1,4 @@ +import { ProgramSelectorViewType } from '../../types/index.ts'; import useStore from '../index.ts'; import { initialThemeEditorState } from './store.ts'; @@ -25,7 +26,9 @@ export const resetPathwayState = () => { }); }; -export const setProgrammingSelectorViewState = (view: string) => { +export const setProgrammingSelectorViewState = ( + view: ProgramSelectorViewType, +) => { useStore.setState((state) => { state.theme.programmingSelectorView = view; }); diff --git a/web/src/store/themeEditor/store.ts b/web/src/store/themeEditor/store.ts index 0bee6078b..20c2b2df6 100644 --- a/web/src/store/themeEditor/store.ts +++ b/web/src/store/themeEditor/store.ts @@ -1,11 +1,12 @@ import dayjs from 'dayjs'; import { StateCreator } from 'zustand'; +import { ProgramSelectorViewType } from '../../types'; export interface ThemeEditorStateInner { darkMode?: boolean | undefined; pathway: string; guideDuration: number; - programmingSelectorView: string; + programmingSelectorView: ProgramSelectorViewType; } export interface ThemeEditorState { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index cd718e246..8caf3cf9e 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -106,3 +106,5 @@ export const isUIRedirectProgram = ( export type UIFillerListProgram = (ContentProgram | CustomProgram) & UIIndex; export type UICustomShowProgram = (ContentProgram | CustomProgram) & UIIndex; export type NonUndefinedGuard = T extends undefined ? never : T; + +export type ProgramSelectorViewType = 'list' | 'grid';