Ability to configure backend URL in frontend. Fixes #326, fixes #324, fixes #323 (#330)

* Checkpoint

* Finish plumbiing backend URI option

* Build fix

* Remove all remaining references to localhost:8000, except for one ini channelLoaders
This commit is contained in:
Christian Benincasa
2024-04-19 09:51:59 -04:00
committed by GitHub
parent a0db9fe482
commit fb8b34166a
52 changed files with 555 additions and 305 deletions

View File

@@ -1 +1,2 @@
pnpm run -r lint-staged
pnpm run --filter=server lint-staged
pnpm run --filter=web lint-staged

3
pnpm-lock.yaml generated
View File

@@ -527,6 +527,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.3
version: 0.4.3(eslint@8.45.0)
lint-staged:
specifier: ^15.2.2
version: 15.2.2
nodemon:
specifier: ^3.0.3
version: 3.1.0

View File

@@ -9,6 +9,7 @@
"bundle": "vite build",
"dev": "vite",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint-staged": "lint-staged",
"preview": "vite preview",
"test": "vitest",
"typecheck": "tsc -p tsconfig.build.json --noEmit"
@@ -62,10 +63,16 @@
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"lint-staged": "^15.2.2",
"nodemon": "^3.0.3",
"openapi-zod-client": "^1.14.0",
"ts-essentials": "^9.4.1",
"typescript": "5.4.3",
"vite": "^4.4.5"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix --no-warn-ignored"
]
}
}

View File

@@ -41,6 +41,7 @@ import DarkModeButton from './components/settings/DarkModeButton.tsx';
import { useVersion } from './hooks/useVersion.ts';
import useStore from './store/index.ts';
import { setDarkModeState } from './store/themeEditor/actions.ts';
import { useSettings } from './store/settings/selectors.ts';
interface NavItem {
name: string;
@@ -66,6 +67,7 @@ export function Root({ children }: { children?: React.ReactNode }) {
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const darkMode = useStore((state) => state.theme.darkMode);
const settings = useSettings();
const { data: version } = useVersion();
@@ -208,7 +210,7 @@ export function Root({ children }: { children?: React.ReactNode }) {
<GitHub />
</IconButton>
<Button
href="//localhost:8000/api/xmltv.xml"
href={`${settings.backendUri}/api/xmltv.xml`}
target="_blank"
color="inherit"
startIcon={<TextSnippetIcon />}
@@ -217,7 +219,7 @@ export function Root({ children }: { children?: React.ReactNode }) {
XMLTV
</Button>
<Button
href="//localhost:8000/api/channels.m3u"
href={`${settings.backendUri}/api/channels.m3u`}
target="_blank"
color="inherit"
startIcon={<TextSnippetIcon />}

View File

@@ -23,6 +23,7 @@ import {
useState,
} from 'react';
import { isNonEmptyString, prettyItemDuration } from '../helpers/util';
import { useSettings } from '../store/settings/selectors';
type Props = {
open: boolean;
@@ -44,6 +45,7 @@ export default function ProgramDetailsDialog({
onClose,
program,
}: Props) {
const settings = useSettings();
const [thumbLoadState, setThumbLoadState] =
useState<ThumbLoadState>('loading');
const imageRef = useRef<HTMLImageElement>(null);
@@ -101,7 +103,7 @@ export default function ProgramDetailsDialog({
if (p.subtype === 'track' && isNonEmptyString(p.albumId)) {
id = p.albumId;
}
url = `http://localhost:8000/api/programs/${id}/thumb?proxy=true`;
url = `${settings.backendUri}/api/programs/${id}/thumb?proxy=true`;
}
if (isNonEmptyString(url)) {
@@ -117,7 +119,7 @@ export default function ProgramDetailsDialog({
);
}
return `http://localhost:8000/api/metadata/external?id=${key}&mode=proxy&asset=thumb`;
return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`;
},
}),
[],
@@ -128,7 +130,7 @@ export default function ProgramDetailsDialog({
forProgramType({
content: (p) =>
p.id && p.persisted
? `http://localhost:8000/api/programs/${p.id}/external-link`
? `${settings.backendUri}/api/programs/${p.id}/external-link`
: null,
}),
[],

View File

@@ -3,8 +3,10 @@ import { TunarrEvent } from '@tunarr/types';
import { TunarrEventSchema } from '@tunarr/types/schemas';
import { first } from 'lodash-es';
import { useEffect, useRef, useState } from 'react';
import { useSettings } from '../store/settings/selectors.ts';
export default function ServerEvents() {
const { backendUri } = useSettings();
const source = useRef<EventSource | null>(null);
const [open, setOpen] = useState(false);
const [eventQueue, setEventQueue] = useState<readonly TunarrEvent[]>([]);
@@ -27,7 +29,7 @@ export default function ServerEvents() {
useEffect(() => {
let es: EventSource | undefined;
if (!source.current) {
es = new EventSource('http://localhost:8000/api/events');
es = new EventSource(`${backendUri}/api/events`);
source.current = es;
es.addEventListener('message', (event: MessageEvent<string>) => {

View File

@@ -0,0 +1,38 @@
import { ReactNode, createContext, useEffect, useState } from 'react';
import { createApiClient } from '../external/api';
import useStore from '../store/index.ts';
import { useSettings } from '../store/settings/selectors';
// HACK ALERT
// Read zustand state out-of-band here (i.e. not in a hook) because we
// need the value available earlier than any components load. This is
// sort of hacky and really just a consequence of the fact that we're
// using react-router's preloaders to fetch data. These preloaders
// do not have access to the normal hook structure and they're overall
// pretty hacky to begin with. A better solution would be to utilize
// suspend queries with react-query, most likely.
let apiClient = createApiClient(useStore.getState().settings.backendUri);
// Gotta be careful using this... we're only exposing this
// for the preloaders. All other usages should come from the
// context API and related hooks.
// eslint-disable-next-line react-refresh/only-export-components
export const getApiClient = () => apiClient;
export const TunarrApiContext = createContext(apiClient);
export function TunarrApiProvider({ children }: { children: ReactNode }) {
const { backendUri } = useSettings();
const [api, setApi] = useState(apiClient);
useEffect(() => {
apiClient = createApiClient(backendUri);
setApi(apiClient);
}, [backendUri]);
return (
<TunarrApiContext.Provider value={api}>
{children}
</TunarrApiContext.Provider>
);
}

View File

@@ -4,16 +4,19 @@ import Hls from 'hls.js';
import { isError, isNil } from 'lodash-es';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useBlocker, useSearchParams } from 'react-router-dom';
import { apiClient } from '../external/api.ts';
import { useFfmpegSettings } from '../hooks/settingsHooks.ts';
import { useHls } from '../hooks/useHls.ts';
import { PlayArrow, Replay } from '@mui/icons-material';
import { useTunarrApi } from '../hooks/useTunarrApi.ts';
import { useSettings } from '../store/settings/selectors.ts';
type VideoProps = {
channelId: string;
};
export default function Video({ channelId }: VideoProps) {
const { backendUri } = useSettings();
const apiClient = useTunarrApi();
const videoRef = useRef<HTMLVideoElement | null>(null);
const { hls, resetHls } = useHls();
const hlsSupported = useMemo(() => Hls.isSupported(), []);
@@ -67,7 +70,7 @@ export default function Video({ channelId }: VideoProps) {
apiClient
.startHlsStream({ params: { channel: channelId } })
.then(({ streamPath }) => {
hls.loadSource(`http://localhost:8000${streamPath}`);
hls.loadSource(`${backendUri}${streamPath}`);
hls.attachMedia(video);
})
.catch((err) => {
@@ -84,6 +87,8 @@ export default function Video({ channelId }: VideoProps) {
canLoadStream,
channelId,
manuallyStarted,
apiClient,
backendUri,
]);
useEffect(() => {

View File

@@ -11,6 +11,7 @@ import useStore from '../../store/index.ts';
import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts';
import { CustomShowSelectedMedia } from '../../store/programmingSelector/store.ts';
import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
type Props = {
onAdd: (items: AddedMedia[]) => void;
@@ -22,6 +23,7 @@ export default function AddSelectedMediaButton({
onSuccess,
...rest
}: Props) {
const apiClient = useTunarrApi();
const knownMedia = useStore((s) => s.knownMediaByServer);
const selectedMedia = useStore((s) => s.selectedMedia);
@@ -33,7 +35,11 @@ export default function AddSelectedMediaButton({
forSelectedMediaType<Promise<AddedMedia[]>>({
plex: async (selected) => {
const media = knownMedia[selected.server][selected.guid];
const items = await enumeratePlexItem(selected.server, media)();
const items = await enumeratePlexItem(
apiClient,
selected.server,
media,
)();
return map(items, (item) => ({ media: item, type: 'plex' }));
},
'custom-show': (

View File

@@ -36,6 +36,7 @@ import {
import useStore from '../../store';
import { addSelectedMedia } from '../../store/programmingSelector/actions';
import { ExpandLess, ExpandMore } from '@mui/icons-material';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
dayjs.extend(duration);
@@ -57,10 +58,11 @@ function CustomShowListItem({
customShow,
selectShow,
}: CustomShowListItemProps) {
const apiClient = useTunarrApi();
const [open, setOpen] = useState(false);
const { data: programs, isPending: programsLoading } = useQuery({
...customShowProgramsQuery(customShow.id),
...customShowProgramsQuery(apiClient, customShow.id),
enabled: open,
});
@@ -138,6 +140,7 @@ function CustomShowListItem({
}
export function CustomShowProgrammingSelector() {
const apiClient = useTunarrApi();
const { data: customShows, isPending } = useCustomShows([]);
const viewType = useStore((state) => state.theme.programmingSelectorView);
const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 });
@@ -161,7 +164,7 @@ export function CustomShowProgrammingSelector() {
async (show: CustomShow) => {
try {
const customShowPrograms = await queryClient.ensureQueryData(
customShowProgramsQuery(show.id),
customShowProgramsQuery(apiClient, show.id),
);
addSelectedMedia({
type: 'custom-show',
@@ -174,7 +177,7 @@ export function CustomShowProgrammingSelector() {
console.error('Error fetching custom show programs', e);
}
},
[queryClient],
[apiClient, queryClient],
);
const renderListItems = () => {

View File

@@ -53,6 +53,7 @@ import { PlexFilterBuilder } from './PlexFilterBuilder.tsx';
import PlexGridItem from './PlexGridItem';
import { PlexListItem } from './PlexListItem';
import { PlexSortField } from './PlexSortField.tsx';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
function a11yProps(index: number) {
return {
@@ -71,6 +72,7 @@ type Size = {
};
export default function PlexProgrammingSelector() {
const apiClient = useTunarrApi();
const { data: plexServers } = usePlexServerSettings();
const selectedServer = useStore((s) => s.currentServer);
const selectedLibrary = useStore((s) =>
@@ -82,7 +84,7 @@ export default function PlexProgrammingSelector() {
const [rowSize, setRowSize] = useState<number>(16);
const [modalIndex, setModalIndex] = useState<number>(-1);
const [modalGuid, setModalGuid] = useState<string>('');
const [modalIsPending, setModalIsPending] = useState<boolean>(true);
const [, setModalIsPending] = useState<boolean>(true);
const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 });
const [searchVisible, setSearchVisible] = useState(false);
const [useAdvancedSearch, setUseAdvancedSearch] = useState(false);
@@ -122,49 +124,56 @@ export default function PlexProgrammingSelector() {
// 16 is additional padding available in the parent container
setRowSize(getImagesPerRow(width ? width + 16 : 0, imageWidth || 0));
}
}, [width, tabValue]);
}, [width, tabValue, viewType, modalGuid]);
const handleModalChildren = useCallback((children: PlexMedia[]) => {
setModalChildren(children);
}, []);
useEffect(() => {
setModalIndex(-1);
setModalGuid('');
handleModalChildren([]);
}, [tabValue]);
}, [handleModalChildren, tabValue]);
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
const scrollToGridItem = (guid: string, index: number) => {
const selectedElement = gridImageRefs.current[guid];
const includeModalInHeightCalc = isNewModalAbove(
previousModalIndex,
index,
rowSize,
);
const scrollToGridItem = useCallback(
(guid: string, index: number) => {
const selectedElement = gridImageRefs.current[guid];
const includeModalInHeightCalc = isNewModalAbove(
previousModalIndex,
index,
rowSize,
);
if (selectedElement) {
// magic number for top bar padding; to do: calc it off ref
const topBarPadding = 64;
// New modal is opening in a row above previous modal
const modalMovesUp = selectedElement.offsetTop - topBarPadding;
// New modal is opening in the same row or a row below the current modal
const modalMovesDown =
selectedElement.offsetTop -
selectedElement.offsetHeight -
topBarPadding;
if (selectedElement) {
// magic number for top bar padding; to do: calc it off ref
const topBarPadding = 64;
// New modal is opening in a row above previous modal
const modalMovesUp = selectedElement.offsetTop - topBarPadding;
// New modal is opening in the same row or a row below the current modal
const modalMovesDown =
selectedElement.offsetTop -
selectedElement.offsetHeight -
topBarPadding;
window.scrollTo({
top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp,
behavior: 'smooth',
});
}
};
window.scrollTo({
top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp,
behavior: 'smooth',
});
}
},
[previousModalIndex, rowSize],
);
// Scroll to new selected item when modalIndex changes
// Doing this on modalIndex change negates the need to calc inline modal height since it's collapsed at this time
useEffect(() => {
scrollToGridItem(modalGuid, modalIndex);
}, [modalIndex]);
}, [modalGuid, modalIndex, scrollToGridItem]);
const handleMoveModal = useCallback(
(item: PlexMedia, index: number) => {
@@ -178,22 +187,12 @@ export default function PlexProgrammingSelector() {
setModalGuid(item.guid);
}
},
[modalIndex],
[handleModalChildren, modalIndex],
);
const handleModalChildren = useCallback(
(children: PlexMedia[]) => {
setModalChildren(children);
},
[modalChildren],
);
const handleModalIsPending = useCallback(
(isPending: boolean) => {
setModalIsPending(isPending);
},
[modalIsPending],
);
const handleModalIsPending = useCallback((isPending: boolean) => {
setModalIsPending(isPending);
}, []);
const { data: directoryChildren } = usePlex(
selectedServer?.name ?? '',
@@ -221,6 +220,7 @@ export default function PlexProgrammingSelector() {
],
queryFn: () => {
return fetchPlexPath<PlexLibraryCollections>(
apiClient,
selectedServer!.name,
`/library/sections/${selectedLibrary?.library.key}/collections?`,
)();
@@ -233,7 +233,7 @@ export default function PlexProgrammingSelector() {
if (!collectionsData && !isCollectionLoading && tabValue === 1) {
setTabValue(0);
}
}, [collectionsData, isCollectionLoading]);
}, [collectionsData, isCollectionLoading, tabValue]);
const { urlFilter: searchKey } = useStore(
({ plexSearch: plexQuery }) => plexQuery,
@@ -271,6 +271,7 @@ export default function PlexProgrammingSelector() {
return fetchPlexPath<
PlexLibraryMovies | PlexLibraryShows | PlexLibraryMusic
>(
apiClient,
selectedServer!.name,
`/library/sections/${
selectedLibrary!.library.key

View File

@@ -31,6 +31,7 @@ import useStore from '../../store';
import ProgramDetailsDialog from '../ProgramDetailsDialog';
import TunarrLogo from '../TunarrLogo';
import PaddedPaper from '../base/PaddedPaper';
import { useSettings } from '../../store/settings/selectors.ts';
const StyledMenu = styled((props: MenuProps) => (
<Menu
@@ -137,6 +138,7 @@ type Props = {
export function TvGuide({ channelId, start, end }: Props) {
const theme = useTheme();
const { backendUri } = useSettings();
// Workaround for issue with page jumping on-zoom or nav caused by collapsing
// div when loading new guide data
@@ -261,7 +263,7 @@ export function TvGuide({ channelId, start, end }: Props) {
</MenuItem>
<MenuItem
disableRipple
to={`http://localhost:8000/media-player/${channelMenu.number}.m3u`}
to={`${backendUri}/media-player/${channelMenu.number}.m3u`}
component={RouterLink}
>
<TextSnippet />

View File

@@ -2,14 +2,15 @@ import { AddCircle } from '@mui/icons-material';
import { Button } from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { InsertPlexServerRequest } from '@tunarr/types/api';
import { apiClient } from '../../external/api.ts';
import { checkNewPlexServers, plexLoginFlow } from '../../helpers/plexLogin.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
type AddPlexServer = {
title?: string;
};
export default function AddPlexServer(props: AddPlexServer) {
const apiClient = useTunarrApi();
const { title = 'Add', ...restProps } = props;
const queryClient = useQueryClient();
@@ -26,7 +27,7 @@ export default function AddPlexServer(props: AddPlexServer) {
const addPlexServer = () => {
plexLoginFlow()
.then(checkNewPlexServers)
.then(checkNewPlexServers(apiClient))
.then((connections) => {
connections.forEach(({ server, connection }) =>
addPlexServerMutation.mutate({

View File

@@ -6,7 +6,7 @@ import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import { styled } from '@mui/material/styles';
import React, { ChangeEvent, useCallback } from 'react';
import { apiClient } from '../../external/api';
import { useTunarrApi } from '../../hooks/useTunarrApi';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
@@ -41,6 +41,7 @@ export function ImageUploadInput({
value,
children,
}: Props) {
const apiClient = useTunarrApi();
const handleFileUpload = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {

View File

@@ -19,8 +19,13 @@ import {
SaveChannelRequestSchema,
TaskSchema,
} from '@tunarr/types/schemas';
import { Zodios, makeApi, makeErrors, parametersBuilder } from '@zodios/core';
import { once } from 'lodash-es';
import {
Zodios,
ZodiosInstance,
makeApi,
makeErrors,
parametersBuilder,
} from '@zodios/core';
import { z } from 'zod';
import {
createPlexServerEndpoint,
@@ -349,8 +354,8 @@ export const api = makeApi([
},
]);
export const createApiClient = once((uri: string) => {
return new Zodios(uri, api);
});
export type ApiClient = ZodiosInstance<typeof api>;
export const apiClient = createApiClient('http://localhost:8000');
export const createApiClient = (uri: string) => {
return new Zodios(uri, api);
};

View File

@@ -1,6 +1,6 @@
import { PlexPinsResponse, PlexResourcesResponse } from '@tunarr/types/plex';
import { compact, partition } from 'lodash-es';
import { apiClient } from '../external/api.ts';
import { ApiClient } from '../external/api.ts';
import { AsyncInterval } from './AsyncInterval.ts';
import { sequentialPromises } from './util.ts';
@@ -25,8 +25,6 @@ export const plexLoginFlow = async () => {
const initialResponseBody =
(await initialResponse.json()) as PlexPinsResponse;
console.log(initialResponseBody);
const plexWindowSizes = {
width: 800,
height: 700,
@@ -101,23 +99,24 @@ export const plexLoginFlow = async () => {
return serversResponse.filter((server) => server.provides.includes('server'));
};
export const checkNewPlexServers = async (servers: PlexResourcesResponse) => {
return sequentialPromises(servers, async (server) => {
const [localConnections, remoteConnections] = partition(
server.connections,
(c) => c.local,
);
export const checkNewPlexServers =
(apiClient: ApiClient) => async (servers: PlexResourcesResponse) => {
return sequentialPromises(servers, async (server) => {
const [localConnections, remoteConnections] = partition(
server.connections,
(c) => c.local,
);
for (const connection of [...localConnections, ...remoteConnections]) {
const { status } = await apiClient.getPlexBackendStatus({
name: server.name,
accessToken: server.accessToken,
uri: connection.uri,
});
for (const connection of [...localConnections, ...remoteConnections]) {
const { status } = await apiClient.getPlexBackendStatus({
name: server.name,
accessToken: server.accessToken,
uri: connection.uri,
});
if (status === 1) {
return { server, connection };
if (status === 1) {
return { server, connection };
}
}
}
}).then(compact);
};
}).then(compact);
};

View File

@@ -6,6 +6,8 @@ import {
} from '@tanstack/react-query';
import { LoaderFunctionArgs } from 'react-router-dom';
import { Preloader } from '../types/index.ts';
import { ApiClient } from '../external/api.ts';
import { getApiClient } from '../components/TunarrApiContext.tsx';
export function createPreloader<
T = unknown,
@@ -15,13 +17,15 @@ export function createPreloader<
: T,
>(
query: (
apiClient: ApiClient,
args: LoaderFunctionArgs,
) => UseQueryOptions<TInferred, Error, TInferred, QK>,
callback: (data: TInferred) => void = () => {},
): Preloader<TInferred> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (queryClient: QueryClient) => async (args) => {
const data: TInferred | undefined = await queryClient.ensureQueryData(
query(args),
query(getApiClient(), args),
);
callback(data);
return data;

View File

@@ -17,10 +17,12 @@ import {
import axios from 'axios';
import { flattenDeep, map } from 'lodash-es';
import { useEffect } from 'react';
import { apiClient } from '../external/api.ts';
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],
@@ -47,7 +49,11 @@ type ExtractTypeKeys<
? Head | ExtractTypeKeys<Tail>
: never;
export const fetchPlexPath = <T>(serverName: string, path: string) => {
export const fetchPlexPath = <T>(
apiClient: ApiClient,
serverName: string,
path: string,
) => {
return async () => {
return apiClient
.getPlexPath({
@@ -68,9 +74,10 @@ export const usePlex = <
path: string,
enabled: boolean = true,
) =>
useQuery({
useApiQuery({
queryKey: ['plex', serverName, path],
queryFn: fetchPlexPath<OutType>(serverName, path),
queryFn: (apiClient) =>
fetchPlexPath<OutType>(apiClient, serverName, path)(),
enabled,
});
@@ -87,12 +94,13 @@ type PlexQueryArgs<T> = {
};
export const plexQueryOptions = <T>(
apiClient: ApiClient,
serverName: string,
path: string,
enabled: boolean = true,
) => ({
queryKey: ['plex', serverName, path],
queryFn: fetchPlexPath<T>(serverName, path),
queryFn: fetchPlexPath<T>(apiClient, serverName, path),
enabled: enabled && serverName.length > 0 && path.length > 0,
});
@@ -100,7 +108,10 @@ export const usePlexTyped = <T>(
serverName: string,
path: string,
enabled: boolean = true,
) => useQuery(plexQueryOptions<T>(serverName, path, enabled));
) => {
const apiClient = useTunarrApi();
return useQuery(plexQueryOptions<T>(apiClient, serverName, path, enabled));
};
/**
* Like {@link usePlexTyped} but accepts two queries that each return
@@ -108,11 +119,13 @@ export const usePlexTyped = <T>(
*/
export const usePlexTyped2 = <T = unknown, U = unknown>(
args: [PlexQueryArgs<T>, PlexQueryArgs<U>],
) =>
useQueries({
) => {
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,
),
@@ -127,6 +140,7 @@ export const usePlexTyped2 = <T = unknown, U = unknown>(
};
},
});
};
export const usePlexServerStatus = (server: PlexServerSettings) => {
return useQuery({
@@ -149,9 +163,11 @@ export const usePlexServerStatus = (server: PlexServerSettings) => {
};
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<PlexFiltersResponse>({
...plexQueryOptions(
apiClient,
serverName,
key,
serverName.length > 0 && plexKey.length > 0,
@@ -191,6 +207,7 @@ export const useSelectedLibraryPlexFilters = () => {
};
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,
@@ -200,7 +217,7 @@ export const usePlexTags = (key: string) => {
: '';
return useQuery<PlexTagResult>({
...plexQueryOptions(selectedServer?.name ?? '', path),
...plexQueryOptions(apiClient, selectedServer?.name ?? '', path),
});
};
@@ -218,11 +235,12 @@ function plexItemExternalId(serverName: string, media: PlexTerminalMedia) {
}
export const enumeratePlexItem = (
apiClient: ApiClient,
serverName: string,
initialItem: PlexMedia | PlexLibrarySection,
): (() => Promise<EnrichedPlexMedia[]>) => {
const fetchPlexPathFunc = <T>(path: string) =>
fetchPlexPath<T>(serverName, path)();
fetchPlexPath<T>(apiClient, serverName, path)();
async function loopInner(
item: PlexMedia | PlexLibrarySection,

View File

@@ -1,32 +1,34 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { apiClient } from '../external/api.ts';
import { useQueries } from '@tanstack/react-query';
import { useApiQuery } from './useApiQuery.ts';
import { useTunarrApi } from './useTunarrApi.ts';
export const useXmlTvSettings = () =>
useQuery({
useApiQuery({
queryKey: ['settings', 'xmltv'],
queryFn: () => apiClient.getXmlTvSettings(),
queryFn: (apiClient) => apiClient.getXmlTvSettings(),
});
export const useFfmpegSettings = () =>
useQuery({
useApiQuery({
queryKey: ['settings', 'ffmpeg'],
queryFn: () => apiClient.getFfmpegSettings(),
queryFn: (apiClient) => apiClient.getFfmpegSettings(),
});
export const usePlexServerSettings = () =>
useQuery({
useApiQuery({
queryKey: ['settings', 'plex-servers'],
queryFn: () => apiClient.getPlexServers(),
queryFn: (apiClient) => apiClient.getPlexServers(),
});
export const usePlexStreamSettings = () =>
useQuery({
useApiQuery({
queryKey: ['settings', 'plex-stream'],
queryFn: () => apiClient.getPlexStreamSettings(),
queryFn: (apiClient) => apiClient.getPlexStreamSettings(),
});
export const usePlexSettings = () =>
useQueries({
export const usePlexSettings = () => {
const apiClient = useTunarrApi();
return useQueries({
queries: [
{
queryKey: ['settings', 'plex-servers'],
@@ -56,9 +58,10 @@ export const usePlexSettings = () =>
};
},
});
};
export const useHdhrSettings = () =>
useQuery({
useApiQuery({
queryKey: ['settings', 'hdhr'],
queryFn: () => apiClient.getHdhrSettings(),
queryFn: (apiClient) => apiClient.getHdhrSettings(),
});

View File

@@ -0,0 +1,38 @@
import {
DefaultError,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
useQuery,
} from '@tanstack/react-query';
import { ApiClient } from '../external/api';
import { useTunarrApi } from './useTunarrApi';
export function useApiQuery<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryFn'
> & {
queryFn: (
apiClient: ApiClient,
...rest: Parameters<QueryFunction<TQueryFnData, TQueryKey, never>>
) => ReturnType<QueryFunction<TQueryFnData, TQueryKey, never>>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
const apiClient = useTunarrApi();
return useQuery(
{
...options,
queryFn: (args) => options.queryFn(apiClient, args),
},
queryClient,
);
}

View File

@@ -1,9 +1,14 @@
import { DataTag, useQueries, useQuery } from '@tanstack/react-query';
import { Channel, CondensedChannelProgramming } from '@tunarr/types';
import { apiClient } from '../external/api.ts';
import { channelQuery } from './useChannels.ts';
import { ApiClient } from '../external/api.ts';
import { useTunarrApi } from './useTunarrApi.ts';
export const channelProgrammingQuery = (id: string, enabled: boolean) => {
export const channelProgrammingQuery = (
apiClient: ApiClient,
id: string,
enabled: boolean,
) => {
return {
queryKey: ['channels', id, 'programming'] as DataTag<
['channels', string, 'programming'],
@@ -18,19 +23,24 @@ export const channelProgrammingQuery = (id: string, enabled: boolean) => {
};
export const useChannelProgramming = (id: string, enabled: boolean = true) => {
return useQuery(channelProgrammingQuery(id, enabled));
const apiClient = useTunarrApi();
return useQuery(channelProgrammingQuery(apiClient, id, enabled));
};
export const useChannelAndProgramming = (
id: string,
enabled: boolean = true,
initialData?: { channel?: Channel; lineup?: CondensedChannelProgramming },
) =>
useQueries({
) => {
const apiClient = useTunarrApi();
return useQueries({
queries: [
{ ...channelQuery(id, enabled), initialData: initialData?.channel },
{
...channelProgrammingQuery(id, enabled),
...channelQuery(apiClient, id, enabled),
initialData: initialData?.channel,
},
{
...channelProgrammingQuery(apiClient, id, enabled),
initialData: initialData?.lineup,
},
],
@@ -45,3 +55,4 @@ export const useChannelAndProgramming = (
};
},
});
};

View File

@@ -1,17 +1,27 @@
import { DataTag, useQuery } from '@tanstack/react-query';
import { Channel } from '@tunarr/types';
import { apiClient } from '../external/api.ts';
import { ApiClient } from '../external/api';
import { useTunarrApi } from './useTunarrApi';
export const channelsQuery = (initialData: Channel[] = []) => ({
export const channelsQuery = (
apiClient: ApiClient,
initialData: Channel[] = [],
) => ({
queryKey: ['channels'] as DataTag<['channels'], Channel[]>,
queryFn: () => apiClient.get('/api/channels'),
initialData,
});
export const useChannels = (initialData: Channel[] = []) =>
useQuery(channelsQuery(initialData));
export const useChannels = (initialData: Channel[] = []) => {
const apiClient = useTunarrApi();
return useQuery(channelsQuery(apiClient, initialData));
};
export const channelQuery = (id: string, enabled: boolean = true) => ({
export const channelQuery = (
apiClient: ApiClient,
id: string,
enabled: boolean = true,
) => ({
queryKey: ['channels', id] as DataTag<['channels', string], Channel>,
queryFn: async () =>
apiClient.get('/api/channels/:id', {
@@ -24,7 +34,10 @@ export const useChannel = (
id: string,
enabled: boolean = true,
initialData: Channel | undefined = undefined,
) => useQuery({ ...channelQuery(id, enabled), initialData });
) => {
const apiClient = useTunarrApi();
return useQuery({ ...channelQuery(apiClient, id, enabled), initialData });
};
// If we absolutely have initialData defined, we can use this hook instead,
// to eliminate the typing possiblity of "| undefined" for the resulting Channel
@@ -32,4 +45,7 @@ export const useChannelWithInitialData = (
id: string,
initialData: Channel,
enabled: boolean = true,
) => useQuery({ ...channelQuery(id, enabled), initialData });
) => {
const apiClient = useTunarrApi();
return useQuery({ ...channelQuery(apiClient, id, enabled), initialData });
};

View File

@@ -5,9 +5,10 @@ import {
useQuery,
} from '@tanstack/react-query';
import { CustomShow } from '@tunarr/types';
import { apiClient } from '../external/api.ts';
import { ApiClient } from '../external/api.ts';
import { ZodiosAliasReturnType } from '../types/index.ts';
import { makeQueryOptionsInitialData } from './useQueryHelpers.ts';
import { useTunarrApi } from './useTunarrApi.ts';
export type CustomShowsQueryOpts = Omit<
DefinedInitialDataOptions<
@@ -20,6 +21,7 @@ export type CustomShowsQueryOpts = Omit<
>;
export const customShowsQuery = (
apiClient: ApiClient,
initialData: CustomShow[] = [],
opts?: CustomShowsQueryOpts,
) =>
@@ -33,9 +35,12 @@ export const customShowsQuery = (
export const useCustomShows = (
initialData: CustomShow[] = [],
opts?: CustomShowsQueryOpts,
) => useQuery(customShowsQuery(initialData, opts ?? {}));
) => {
const apiClient = useTunarrApi();
return useQuery(customShowsQuery(apiClient, initialData, opts ?? {}));
};
export const customShowQuery = (id: string) => ({
export const customShowQuery = (apiClient: ApiClient, id: string) => ({
queryKey: ['custom-shows', id] as DataTag<
['custom-shows', string],
ZodiosAliasReturnType<'getCustomShow'>
@@ -43,7 +48,7 @@ export const customShowQuery = (id: string) => ({
queryFn: () => apiClient.getCustomShow({ params: { id } }),
});
export const customShowProgramsQuery = (id: string) => ({
export const customShowProgramsQuery = (apiClient: ApiClient, id: string) => ({
queryKey: ['custom-shows', id, 'programs'] as DataTag<
['custom-shows', string, 'programs'],
ZodiosAliasReturnType<'getCustomShowPrograms'>
@@ -52,15 +57,16 @@ export const customShowProgramsQuery = (id: string) => ({
});
export const useCustomShow = (
apiClient: ApiClient,
id: string,
enabled: boolean,
includePrograms: boolean,
) => {
return useQueries({
queries: [
{ ...customShowQuery(id), enabled },
{ ...customShowQuery(apiClient, id), enabled },
{
...customShowProgramsQuery(id),
...customShowProgramsQuery(apiClient, id),
enabled: enabled && includePrograms,
},
],

View File

@@ -1,15 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../external/api.ts';
import useStore from '../store/index.ts';
import { makeQueryOptions } from './useQueryHelpers.ts';
import { ApiClient } from '../external/api.ts';
import { useTunarrApi } from './useTunarrApi.ts';
export const fillerListsQuery = makeQueryOptions(['fillers'], () =>
apiClient.getFillerLists(),
);
export const fillerListsQuery = (apiClient: ApiClient) =>
makeQueryOptions(['fillers'], () => apiClient.getFillerLists());
export const useFillerLists = () => useQuery(fillerListsQuery);
export const useFillerLists = () => {
return useQuery(fillerListsQuery(useTunarrApi()));
};
export const fillerListQuery = (id: string) =>
export const fillerListQuery = (apiClient: ApiClient, id: string) =>
makeQueryOptions(['fillers', id], () =>
apiClient.getFillerList({ params: { id } }),
);
@@ -17,7 +19,7 @@ export const fillerListQuery = (id: string) =>
export const useCurrentFillerList = () =>
useStore((s) => s.fillerListEditor.currentEntity);
export const fillerListProgramsQuery = (id: string) =>
export const fillerListProgramsQuery = (apiClient: ApiClient, id: string) =>
makeQueryOptions(['fillers', id, 'programs'], () =>
apiClient.getFillerListPrograms({ params: { id } }),
);

View File

@@ -1,60 +0,0 @@
import { TunarrEvent } from '@tunarr/types';
import { TunarrEventSchema } from '@tunarr/types/schemas';
import { each, once, remove } from 'lodash-es';
import { useCallback, useEffect, useState } from 'react';
type Callback = (event: TunarrEvent) => void;
const observers: Callback[] = [];
let es: EventSource | null;
// Don't really use this yet -- it doesn't play well with HMR
const initEventSource = once(() => {
if (es) {
return;
}
es = new EventSource('http://localhost:8000/api/events');
es.addEventListener('message', (event: MessageEvent<string>) => {
const parsed = TunarrEventSchema.safeParse(JSON.parse(event.data));
if (parsed.success) {
console.log('again');
each(observers, (o) => o(parsed.data));
// if (parsed.data.type !== 'heartbeat') {
// }
} else {
console.error(parsed.error);
}
});
});
export const useServerEvents = (callback: Callback) => {
initEventSource();
const [cb, setCb] = useState(() => callback);
useEffect(() => {
observers.push(cb);
return () => {
remove(observers, (o) => o === cb);
if (observers.length === 0) {
es = null;
}
};
}, [cb]);
const resetCb = useCallback(
(newCb: Callback) => {
if (cb == newCb) {
return;
}
remove(observers, (o) => o === cb);
setCb(() => newCb);
},
[cb, setCb],
);
return [cb, resetCb];
};

View File

@@ -0,0 +1,21 @@
import { useContext } from 'react';
import { TunarrApiContext } from '../components/TunarrApiContext';
export const useTunarrApi = () => {
// const { backendUri } = useSettings();
// const [api, setApi] = useState(createApiClient(backendUri));
// const queryClient = useQueryClient();
// useEffect(() => {
// setApi(createApiClient(backendUri));
// // We have to reset everything when the backend URL changes!
// queryClient.resetQueries().catch(console.warn);
// }, [backendUri, queryClient]);
// return api;
return useContext(TunarrApiContext);
};
// export const useWithTunarrApi = () => {
// const useTunarrApi()
// }

View File

@@ -1,5 +1,4 @@
import {
QueryClient,
UseQueryOptions,
UseQueryResult,
useQuery,
@@ -7,13 +6,15 @@ import {
} from '@tanstack/react-query';
import { ChannelLineup } from '@tunarr/types';
import { Dayjs } from 'dayjs';
import { apiClient } from '../external/api.ts';
import { identity, isUndefined } from 'lodash-es';
import { ApiClient } from '../external/api.ts';
import { useTunarrApi } from './useTunarrApi.ts';
const dateRangeQueryKey = (range: { from: Dayjs; to: Dayjs }) =>
`${range.from.unix()}_${range.to.unix()}`;
function lineupQueryOpts<Out = ChannelLineup | undefined>(
apiClient: ApiClient,
channelId: string,
range: { from: Dayjs; to: Dayjs },
mapper: (lineup: ChannelLineup | undefined) => Out = identity,
@@ -34,10 +35,13 @@ function lineupQueryOpts<Out = ChannelLineup | undefined>(
};
}
const allLineupsQueryOpts = (range: {
from: Dayjs;
to: Dayjs;
}): UseQueryOptions<ChannelLineup[]> => ({
const allLineupsQueryOpts = (
apiClient: ApiClient,
range: {
from: Dayjs;
to: Dayjs;
},
): UseQueryOptions<ChannelLineup[]> => ({
queryKey: ['channels', 'all', 'guide', dateRangeQueryKey(range)],
queryFn: async () => {
return apiClient.get('/api/channels/all/lineups', {
@@ -53,18 +57,24 @@ export const useTvGuide = (params: {
channelId: string;
from: Dayjs;
to: Dayjs;
}) =>
useQuery(
lineupQueryOpts(params.channelId, { from: params.from, to: params.to }),
}) => {
const client = useTunarrApi();
return useQuery(
lineupQueryOpts(client, params.channelId, {
from: params.from,
to: params.to,
}),
);
};
export const useTvGuides = (
channelId: string,
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
): UseQueryResult<ChannelLineup[], Error> => {
const client = useTunarrApi();
const singleChannelResult = useQuery({
...lineupQueryOpts(channelId, params, (lineup) =>
...lineupQueryOpts(client, channelId, params, (lineup) =>
!isUndefined(lineup) ? [lineup] : [],
),
...extraOpts,
@@ -84,17 +94,18 @@ export const useTvGuidesPrefetch = (
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
) => {
const client = useTunarrApi();
const queryClient = useQueryClient();
const query: UseQueryOptions<ChannelLineup[]> =
channelId !== 'all'
? {
...lineupQueryOpts(channelId, params, (lineup) =>
...lineupQueryOpts(client, channelId, params, (lineup) =>
!isUndefined(lineup) ? [lineup] : [],
),
...extraOpts,
}
: {
...allLineupsQueryOpts(params),
...allLineupsQueryOpts(client, params),
...extraOpts,
};
@@ -104,15 +115,19 @@ export const useTvGuidesPrefetch = (
export const useAllTvGuides = (
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
) => useQuery({ ...allLineupsQueryOpts(params), ...extraOpts });
) => {
const client = useTunarrApi();
return useQuery({ ...allLineupsQueryOpts(client, params), ...extraOpts });
};
export const useAllTvGuidesDebug = (
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<
UseQueryOptions<{ old: ChannelLineup; new: ChannelLineup }[]>
> = {},
) =>
useQuery({
) => {
const apiClient = useTunarrApi();
return useQuery({
queryKey: ['channels', 'all', 'guide', dateRangeQueryKey(params)],
queryFn: async () => {
return apiClient.getAllChannelLineupsDebug({
@@ -124,15 +139,4 @@ export const useAllTvGuidesDebug = (
},
...extraOpts,
});
export const prefetchAllTvGuides =
(queryClient: QueryClient) =>
async (
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
) => {
return await queryClient.prefetchQuery({
...allLineupsQueryOpts(params),
...extraOpts,
});
};
};

View File

@@ -2,11 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SaveChannelRequest } from '@tunarr/types';
import { ZodiosError } from '@zodios/core';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../external/api';
import { useTunarrApi } from './useTunarrApi';
export const useUpdateChannel = (isNewChannel: boolean) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const apiClient = useTunarrApi();
const updateChannel = useMutation({
mutationFn: async (channelUpdates: SaveChannelRequest) => {

View File

@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UpdateChannelProgrammingRequest } from '@tunarr/types/api';
import { ZodiosError } from '@zodios/core';
import { apiClient } from '../external/api';
import { useTunarrApi } from './useTunarrApi';
type MutateArgs = {
channelId: string;
@@ -9,6 +9,7 @@ type MutateArgs = {
};
export const useUpdateLineup = () => {
const apiClient = useTunarrApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ channelId, lineupRequest }: MutateArgs) => {

View File

@@ -1,11 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../external/api.ts';
import { useApiQuery } from './useApiQuery.ts';
export const useVersion = () =>
useQuery({
export const useVersion = () => {
return useApiQuery({
queryKey: ['version'],
queryFn: () => {
queryFn: (apiClient) => {
return apiClient.getServerVersions();
},
staleTime: 30 * 1000,
});
};

View File

@@ -10,17 +10,20 @@ import './helpers/dayjs.ts';
import './index.css';
import { queryCache } from './queryClient.ts';
import { router } from './router.tsx';
import { TunarrApiProvider } from './components/TunarrApiContext.tsx';
const queryClient = new QueryClient({ queryCache });
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DndProvider backend={HTML5Backend}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</DndProvider>
</LocalizationProvider>
<TunarrApiProvider>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DndProvider backend={HTML5Backend}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</DndProvider>
</LocalizationProvider>
</TunarrApiProvider>
</React.StrictMode>,
);

View File

@@ -14,7 +14,6 @@ import { chain, findIndex, first, isUndefined, map } from 'lodash-es';
import { useState } from 'react';
import Breadcrumbs from '../../components/Breadcrumbs.tsx';
import { ChannelProgrammingConfig } from '../../components/channel_config/ChannelProgrammingConfig.tsx';
import { apiClient } from '../../external/api.ts';
import { channelProgramUniqueId } from '../../helpers/util.ts';
import { usePreloadedChannelEdit } from '../../hooks/usePreloadedChannel.ts';
import { useUpdateChannel } from '../../hooks/useUpdateChannel.ts';
@@ -23,6 +22,7 @@ import {
resetLineup,
} from '../../store/channelEditor/actions.ts';
import useStore from '../../store/index.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
type MutateArgs = {
channelId: string;
@@ -44,6 +44,7 @@ export default function ChannelProgrammingPage() {
const programsDirty = useStore((s) => s.channelEditor.dirty.programs);
const apiClient = useTunarrApi();
const queryClient = useQueryClient();
const theme = useTheme();

View File

@@ -33,10 +33,11 @@ import { useState } from 'react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import TunarrLogo from '../../components/TunarrLogo.tsx';
import PaddedPaper from '../../components/base/PaddedPaper.tsx';
import { apiClient } from '../../external/api.ts';
import { useChannels } from '../../hooks/useChannels.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
export default function ChannelsPage() {
const apiClient = useTunarrApi();
const now = dayjs();
const {
isPending: channelsLoading,

View File

@@ -15,12 +15,13 @@ import Typography from '@mui/material/Typography';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import Breadcrumbs from '../../components/Breadcrumbs.tsx';
import { apiClient } from '../../external/api.ts';
import { usePreloadedData } from '../../hooks/preloadedDataHook.ts';
import { useCustomShows } from '../../hooks/useCustomShows.ts';
import { customShowsLoader } from '../../preloaders/customShowLoaders.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
export default function CustomShowsPage() {
const apiClient = useTunarrApi();
const { data: customShows } = useCustomShows(
usePreloadedData(customShowsLoader),
);

View File

@@ -23,7 +23,6 @@ import Breadcrumbs from '../../components/Breadcrumbs.tsx';
import PaddedPaper from '../../components/base/PaddedPaper.tsx';
import AddSelectedMediaButton from '../../components/channel_config/AddSelectedMediaButton.tsx';
import ProgrammingSelector from '../../components/channel_config/ProgrammingSelector.tsx';
import { apiClient } from '../../external/api.ts';
import { usePreloadedData } from '../../hooks/preloadedDataHook.ts';
import {
existingCustomShowLoader,
@@ -35,6 +34,7 @@ import {
} from '../../store/channelEditor/actions.ts';
import useStore from '../../store/index.ts';
import { UICustomShowProgram } from '../../types/index.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
type Props = { isNew: boolean };
@@ -43,6 +43,7 @@ type CustomShowForm = {
};
export default function EditCustomShowPage({ isNew }: Props) {
const apiClient = useTunarrApi();
const { show: customShow } = usePreloadedData(
isNew ? existingCustomShowLoader : newCustomShowLoader,
);
@@ -63,7 +64,6 @@ export default function EditCustomShowPage({ isNew }: Props) {
});
useEffect(() => {
console.log(customShow, 'reset');
reset({
name: customShow.name,
});

View File

@@ -23,7 +23,6 @@ import Breadcrumbs from '../../components/Breadcrumbs.tsx';
import PaddedPaper from '../../components/base/PaddedPaper.tsx';
import AddSelectedMediaButton from '../../components/channel_config/AddSelectedMediaButton.tsx';
import ProgrammingSelector from '../../components/channel_config/ProgrammingSelector.tsx';
import { apiClient } from '../../external/api.ts';
import { useCurrentFillerList } from '../../hooks/useFillerLists.ts';
import {
addMediaToCurrentFillerList,
@@ -31,6 +30,7 @@ import {
} from '../../store/channelEditor/actions.ts';
import useStore from '../../store/index.ts';
import { UIFillerListProgram } from '../../types/index.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
type Props = { isNew: boolean };
@@ -43,6 +43,7 @@ type FillerListMutationArgs = {
type FillerListFormType = Omit<FillerListMutationArgs, 'id'>;
export default function EditFillerPage({ isNew }: Props) {
const apiClient = useTunarrApi();
const fillerList = useCurrentFillerList()!;
const fillerListPrograms = useStore((s) => s.fillerListEditor.programList);
const queryClient = useQueryClient();

View File

@@ -15,12 +15,13 @@ import Typography from '@mui/material/Typography';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import Breadcrumbs from '../../components/Breadcrumbs.tsx';
import { apiClient } from '../../external/api.ts';
import { useFillerLists } from '../../hooks/useFillerLists.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
type DeleteFillerListRequest = { id: string };
export default function FillerListsPage() {
const apiClient = useTunarrApi();
// This should always be defined because of the preloader
const { data: fillerLists } = useFillerLists();
const queryClient = useQueryClient();

View File

@@ -24,7 +24,7 @@ import {
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FfmpegSettings, defaultFfmpegSettings } from '@tunarr/types';
import _ from 'lodash-es';
import React, { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import UnsavedNavigationAlert from '../../components/settings/UnsavedNavigationAlert.tsx';
import {
@@ -32,13 +32,13 @@ import {
NumericFormControllerText,
TypedController,
} from '../../components/util/TypedController.tsx';
import { apiClient } from '../../external/api.ts';
import {
handleNumericFormValue,
resolutionFromAnyString,
resolutionToString,
} from '../../helpers/util.ts';
import { useFfmpegSettings } from '../../hooks/settingsHooks.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
const supportedVideoBuffer = [
{ value: 0, string: '0 Seconds' },
@@ -132,6 +132,7 @@ type DeinterlaceFilterValue =
| 'yadif=1';
export default function FfmpegSettingsPage() {
const apiClient = useTunarrApi();
const { data, isPending, error } = useFfmpegSettings();
const {
@@ -153,9 +154,8 @@ export default function FfmpegSettingsPage() {
}
}, [data, reset]);
const [snackStatus, setSnackStatus] = React.useState<boolean>(false);
const [restoreTunarrDefaults, setRestoreTunarrDefaults] =
React.useState<boolean>(false);
const [snackStatus, setSnackStatus] = useState(false);
const [restoreTunarrDefaults, setRestoreTunarrDefaults] = useState(false);
const queryClient = useQueryClient();

View File

@@ -1,14 +1,79 @@
import Stack from '@mui/material/Stack';
import DarkModeButton from '../../components/settings/DarkModeButton.tsx';
import Button from '@mui/material/Button';
import { useSettings } from '../../store/settings/selectors.ts';
import { Controller, useForm } from 'react-hook-form';
import { attempt, isError } from 'lodash-es';
import { Box, Divider, Snackbar, TextField, Typography } from '@mui/material';
import { setBackendUri } from '../../store/settings/actions.ts';
import { useState } from 'react';
type GeneralSettingsForm = {
backendUri: string;
};
function isValidUrl(url: string) {
return !isError(attempt(() => new URL(url)));
}
export default function GeneralSettingsPage() {
const settings = useSettings();
const [snackStatus, setSnackStatus] = useState(false);
const { control, handleSubmit } = useForm<GeneralSettingsForm>({
reValidateMode: 'onBlur',
defaultValues: settings,
});
const onSave = (data: GeneralSettingsForm) => {
setBackendUri(data.backendUri);
setSnackStatus(true);
};
return (
<>
<DarkModeButton />
{/* This is currently not needed for this page as Dark Mode saves automatically */}
{/* <Stack spacing={2} direction="row" justifyContent="right" sx={{ mt: 2 }}>
<Box component="form" onSubmit={handleSubmit(onSave, console.error)}>
<Snackbar
open={snackStatus}
autoHideDuration={6000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
onClose={() => setSnackStatus(false)}
message="Settings Saved!"
/>
<Stack direction="column" gap={2}>
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Theme Settings
</Typography>
<DarkModeButton />
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Server Settings
</Typography>
<Controller
control={control}
name="backendUri"
rules={{ validate: { isValidUrl } }}
render={({ field, fieldState: { error } }) => (
<TextField
fullWidth
label="Tunarr Backend URL"
{...field}
helperText={
error?.type === 'isValidUrl' ? 'Must use a valid URL' : ''
}
/>
)}
/>
</Box>
</Stack>
<Stack spacing={2} direction="row" justifyContent="right" sx={{ mt: 2 }}>
<Button variant="outlined">Reset Options</Button>
<Button variant="contained">Save</Button>
</Stack> */}
</>
<Button variant="contained" type="submit">
Save
</Button>
</Stack>
</Box>
);
}

View File

@@ -18,10 +18,11 @@ import {
CheckboxFormController,
NumericFormControllerText,
} from '../../components/util/TypedController.tsx';
import { apiClient } from '../../external/api.ts';
import { useHdhrSettings } from '../../hooks/settingsHooks.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
export default function HdhrSettingsPage() {
const apiClient = useTunarrApi();
const [restoreTunarrDefaults, setRestoreTunarrDefaults] =
React.useState<boolean>(false);

View File

@@ -64,7 +64,6 @@ import {
NumericFormControllerText,
TypedController,
} from '../../components/util/TypedController.tsx';
import { apiClient } from '../../external/api.ts';
import {
handleNumericFormValue,
resolutionFromAnyString,
@@ -76,6 +75,7 @@ import {
usePlexServerSettings,
usePlexStreamSettings,
} from '../../hooks/settingsHooks.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
const supportedResolutions = [
'420x420',
@@ -127,6 +127,7 @@ function PlexServerDeleteDialog({
onClose,
serverId,
}: PlexServerDeleteDialogProps) {
const apiClient = useTunarrApi();
const queryClient = useQueryClient();
const removePlexServerMutation = useMutation({
mutationFn: (id: string) => {
@@ -173,6 +174,7 @@ type PlexServerRowProps = {
};
function PlexServerRow({ server }: PlexServerRowProps) {
const apiClient = useTunarrApi();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [showAccessToken, setShowAccessToken] = useState(false);
@@ -414,6 +416,7 @@ function PlexServerRow({ server }: PlexServerRowProps) {
}
export default function PlexSettingsPage() {
const apiClient = useTunarrApi();
const [restoreTunarrDefaults, setRestoreTunarrDefaults] =
React.useState<boolean>(false);

View File

@@ -10,11 +10,12 @@ import {
Typography,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Task } from '@tunarr/types';
import dayjs from 'dayjs';
import { map } from 'lodash-es';
import { apiClient } from '../../external/api.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
import { useApiQuery } from '../../hooks/useApiQuery.ts';
const StyledLoopIcon = styled(Loop)({
animation: 'spin 2s linear infinite',
@@ -30,6 +31,7 @@ const StyledLoopIcon = styled(Loop)({
// Separated so we can track mutation state individually
function TaskRow({ task }: { task: Task }) {
const apiClient = useTunarrApi();
const queryClient = useQueryClient();
const runJobMutation = useMutation({
@@ -98,9 +100,9 @@ function TaskRow({ task }: { task: Task }) {
}
export default function TaskSettingsPage() {
const { isPending, data: tasks } = useQuery({
const { isPending, data: tasks } = useApiQuery({
queryKey: ['jobs'],
queryFn: async () => {
queryFn: async (apiClient) => {
return apiClient.getTasks();
},
refetchInterval: 60 * 1000, // Check tasks every minute

View File

@@ -18,10 +18,11 @@ import {
CheckboxFormController,
NumericFormControllerText,
} from '../../components/util/TypedController.tsx';
import { apiClient } from '../../external/api.ts';
import { useXmlTvSettings } from '../../hooks/settingsHooks.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
export default function XmlTvSettingsPage() {
const apiClient = useTunarrApi();
const [restoreTunarrDefaults, setRestoreTunarrDefaults] =
React.useState<boolean>(false);
const { data, isPending, error } = useXmlTvSettings();

View File

@@ -4,6 +4,7 @@ import dayjs from 'dayjs';
import { isNil, maxBy } from 'lodash-es';
import { LoaderFunctionArgs } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import { getApiClient } from '../components/TunarrApiContext.tsx';
import { createPreloader } from '../helpers/preloaderUtil.ts';
import { channelProgrammingQuery } from '../hooks/useChannelLineup.ts';
import { channelQuery, channelsQuery } from '../hooks/useChannels.ts';
@@ -14,10 +15,6 @@ import {
import useStore from '../store/index.ts';
import { Preloader } from '../types/index.ts';
export const newChannelLoader: Preloader<Channel[]> = createPreloader(() =>
channelsQuery(),
);
// Default channel values that aren't dynamic
export const DefaultChannel = {
duration: 0,
@@ -34,6 +31,7 @@ export const DefaultChannel = {
disableFillerOverlay: false,
offline: {
mode: 'pic',
// TODO: Make this work with the backend settings
picture: 'http://localhost:8000/images/generic-offline-screen.png',
},
} as const;
@@ -68,7 +66,7 @@ function updateChannelState(
}
export const channelLoader: Preloader<Channel> = createPreloader(
({ params }) => channelQuery(params.id!),
(apiClient, { params }) => channelQuery(apiClient, params.id!),
updateChannelState,
);
@@ -76,9 +74,9 @@ export const channelLoader: Preloader<Channel> = createPreloader(
export const editChannelLoader = (isNew: boolean): Preloader<Channel> => {
if (isNew) {
return (queryClient) => async (args) => {
const channels = await createPreloader(() => channelsQuery())(
queryClient,
)(args);
const channels = await createPreloader((apiClient) =>
channelsQuery(apiClient),
)(queryClient)(args);
const newChannel = defaultNewChannel(
(maxBy(channels, (c) => c.number)?.number ?? 0) + 1,
@@ -100,8 +98,13 @@ export const editProgrammingLoader: Preloader<{
}> =
(queryClient: QueryClient) =>
async ({ params }: LoaderFunctionArgs) => {
const lineupQueryOpts = channelProgrammingQuery(params.id!, true);
const channelQueryOpts = channelQuery(params.id!);
const apiClient = getApiClient();
const lineupQueryOpts = channelProgrammingQuery(
apiClient,
params.id!,
true,
);
const channelQueryOpts = channelQuery(apiClient, params.id!);
const lineupPromise = queryClient.ensureQueryData(lineupQueryOpts);
const channelPromise = queryClient.ensureQueryData(channelQueryOpts);

View File

@@ -9,6 +9,7 @@ import {
} from '../hooks/useCustomShows.ts';
import { setCurrentCustomShow } from '../store/channelEditor/actions.ts';
import { Preloader } from '../types/index.ts';
import { getApiClient } from '../components/TunarrApiContext.tsx';
export type CustomShowPreload = {
show: CustomShow;
@@ -18,7 +19,7 @@ export type CustomShowPreload = {
export const customShowLoader = (isNew: boolean): Preloader<CustomShow> => {
if (!isNew) {
return createPreloader(
({ params }) => customShowQuery(params.id!),
(apiClient, { params }) => customShowQuery(apiClient, params.id!),
(show) => setCurrentCustomShow(show, []),
);
} else {
@@ -50,7 +51,10 @@ export const existingCustomShowLoader: Preloader<CustomShowPreload> = (
return async (args: LoaderFunctionArgs) => {
const showLoaderPromise = showLoader(args);
const programQuery = customShowProgramsQuery(args.params.id!);
const programQuery = customShowProgramsQuery(
getApiClient(),
args.params.id!,
);
const programsPromise = queryClient.ensureQueryData(programQuery);
@@ -66,5 +70,5 @@ export const existingCustomShowLoader: Preloader<CustomShowPreload> = (
};
};
export const customShowsLoader: Preloader<CustomShow[]> = createPreloader(() =>
customShowsQuery(),
customShowsQuery(getApiClient()),
);

View File

@@ -13,13 +13,16 @@ import {
import { setCurrentFillerList } from '../store/channelEditor/actions.ts';
import { Preloader } from '../types/index.ts';
import { createPreloader } from '../helpers/preloaderUtil.ts';
import { getApiClient } from '../components/TunarrApiContext.tsx';
export const fillerListsLoader = createPreloader(() => fillerListsQuery);
export const fillerListsLoader = createPreloader((apiClient) =>
fillerListsQuery(apiClient),
);
const fillerListLoader = (isNew: boolean) => {
if (!isNew) {
return createPreloader(
({ params }) => fillerListQuery(params.id!),
(apiClient, { params }) => fillerListQuery(apiClient, params.id!),
(filler) => setCurrentFillerList(filler, []),
);
} else {
@@ -53,7 +56,10 @@ export const existingFillerListLoader: Preloader<{
return async (args: LoaderFunctionArgs) => {
const showLoaderPromise = showLoader(args);
const programQuery = fillerListProgramsQuery(args.params.id!);
const programQuery = fillerListProgramsQuery(
getApiClient(),
args.params.id!,
);
const programsPromise = Promise.resolve(
queryClient.getQueryData(programQuery.queryKey),

View File

@@ -111,7 +111,7 @@ export const router = createBrowserRouter(
{
path: '/channels/:id/watch',
element: <ChannelWatchPage />,
loader: channelLoader(queryClient),
loader: (args) => channelLoader(queryClient)(args),
},
{
path: '/guide',

View File

@@ -1,5 +1,4 @@
import { Channel, XmlTvSettings } from '@tunarr/types';
import { StateCreator, create } from 'zustand';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import {
@@ -14,33 +13,19 @@ import {
ProgrammingListingsState,
createProgrammingListingsState,
} from './programmingSelector/store.ts';
import { SettingsState, createSettingsSlice } from './settings/store.ts';
import {
ThemeEditorState,
createThemeEditorState,
} from './themeEditor/store.ts';
interface ChannelsState {
channels?: Channel[];
}
interface SettingsState {
xmltvSettings?: XmlTvSettings;
}
export type State = ThemeEditorState &
SettingsState &
ChannelsState &
ProgrammingListingsState &
EditorsState &
PlexMetadataState;
const createSettingsSlice: StateCreator<SettingsState> = () => ({
xmlTvSettings: undefined,
});
const createChannelsState: StateCreator<ChannelsState> = () => ({
channels: undefined,
});
type PersistedState = SettingsState & ThemeEditorState;
const useStore = create<State>()(
immer(
@@ -48,7 +33,6 @@ const useStore = create<State>()(
persist(
(...set) => ({
...createSettingsSlice(...set),
...createChannelsState(...set),
...createProgrammingListingsState(...set),
...createChannelEditorState(...set),
...createThemeEditorState(...set),
@@ -56,9 +40,11 @@ const useStore = create<State>()(
}),
{
name: 'tunarr',
partialize: (state: State) => ({
theme: state.theme,
}),
partialize: (state: State) =>
<PersistedState>{
theme: state.theme,
settings: state.settings,
},
},
),
),

View File

@@ -0,0 +1,6 @@
import useStore from '..';
export const setBackendUri = (uri: string) =>
useStore.setState(({ settings }) => {
settings.backendUri = uri;
});

View File

@@ -0,0 +1,5 @@
import useStore from '..';
export const useSettings = () => {
return useStore(({ settings }) => settings);
};

View File

@@ -0,0 +1,17 @@
import { StateCreator } from 'zustand';
interface SettingsStateInternal {
backendUri: string;
}
export interface SettingsState {
settings: SettingsStateInternal;
}
export const DefaultBackendUri = 'http://localhost:8000';
export const createSettingsSlice: StateCreator<SettingsState> = () => ({
settings: {
backendUri: DefaultBackendUri,
},
});

View File

@@ -14,7 +14,7 @@ import {
ZodiosResponseByAlias,
} from '@zodios/core/lib/zodios.types';
import { LoaderFunctionArgs } from 'react-router-dom';
import { apiClient } from '../external/api.ts';
import { type ApiClient } from '../external/api.ts';
import { EnrichedPlexMedia } from '../hooks/plexHooks.ts';
// A program that may or may not exist in the DB yet
@@ -30,7 +30,7 @@ export type PreloadedData<T extends (...args: any[]) => any> = Awaited<
>;
// The expanded type of our API
type ApiType = ApiOf<typeof apiClient>;
type ApiType = ApiOf<ApiClient>;
export type ApiAliases = keyof ZodiosAliases<ApiType>;