mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
* 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:
committed by
GitHub
parent
a0db9fe482
commit
fb8b34166a
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
38
web/src/components/TunarrApiContext.tsx
Normal file
38
web/src/components/TunarrApiContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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': (
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
web/src/external/api.ts
vendored
17
web/src/external/api.ts
vendored
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
38
web/src/hooks/useApiQuery.ts
Normal file
38
web/src/hooks/useApiQuery.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 } }),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
21
web/src/hooks/useTunarrApi.ts
Normal file
21
web/src/hooks/useTunarrApi.ts
Normal 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()
|
||||
// }
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -111,7 +111,7 @@ export const router = createBrowserRouter(
|
||||
{
|
||||
path: '/channels/:id/watch',
|
||||
element: <ChannelWatchPage />,
|
||||
loader: channelLoader(queryClient),
|
||||
loader: (args) => channelLoader(queryClient)(args),
|
||||
},
|
||||
{
|
||||
path: '/guide',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
6
web/src/store/settings/actions.ts
Normal file
6
web/src/store/settings/actions.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import useStore from '..';
|
||||
|
||||
export const setBackendUri = (uri: string) =>
|
||||
useStore.setState(({ settings }) => {
|
||||
settings.backendUri = uri;
|
||||
});
|
||||
5
web/src/store/settings/selectors.ts
Normal file
5
web/src/store/settings/selectors.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import useStore from '..';
|
||||
|
||||
export const useSettings = () => {
|
||||
return useStore(({ settings }) => settings);
|
||||
};
|
||||
17
web/src/store/settings/store.ts
Normal file
17
web/src/store/settings/store.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user