fix: split media source page from scanner settings

remove duplication of media source table
This commit is contained in:
Christian Benincasa
2026-04-07 09:39:18 -04:00
parent c606fa1c86
commit 09ce8d4437
8 changed files with 168 additions and 119 deletions

View File

@@ -69,6 +69,7 @@ export const useTableSettings = (
if (isFunction(updater)) {
sortState[1]((prev) => {
const next = updater(prev);
console.log(next);
setTableSortState(tableName, next);
return next;
});
@@ -111,9 +112,12 @@ export const useStoreBackedTableSettings = <Data extends MRT_RowData>(
state: {
columnVisibility: tableState.colVisibilityState.current,
pagination: tableState.paginationState.current,
sorting: tableState.sortState.current,
},
initialState: {
pagination: tableState.paginationState.current,
columnVisibility: tableState.colVisibilityState.current,
sorting: tableState.sortState.current,
},
onColumnVisibilityChange: (updater) => {
tableState.colVisibilityState.setter(updater);

View File

@@ -4,7 +4,6 @@ import { useMediaSources } from '@/hooks/settingsHooks.ts';
import { Delete, Edit, Refresh, VideoLibrary } from '@mui/icons-material';
import {
Box,
Button,
Divider,
IconButton,
Link,
@@ -12,22 +11,15 @@ import {
Tooltip,
Typography,
} from '@mui/material';
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { MediaSourceSettings } from '@tunarr/types';
import type { GlobalMediaSourceSettings } from '@tunarr/types/schemas';
import { capitalize } from 'lodash-es';
import type { MRT_ColumnDef } from 'material-react-table';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import { useSnackbar } from 'notistack';
import { useCallback, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMemo, useState } from 'react';
import { DeleteConfirmationDialog } from '../../components/DeleteConfirmationDialog.tsx';
import { EditMediaSourceLibrariesDialog } from '../../components/settings/media_source/EditMediaSourceLibrariesDialog.tsx';
import { EmbyServerEditDialog } from '../../components/settings/media_source/EmbyServerEditDialog.tsx';
@@ -35,12 +27,9 @@ import { JellyfinServerEditDialog } from '../../components/settings/media_source
import { LocalMediaEditDialog } from '../../components/settings/media_source/LocalMediaEditDialog.tsx';
import { MediaSourceHealthyTableCell } from '../../components/settings/media_source/MediaSourceHealthyTableCell.tsx';
import { PlexServerEditDialog } from '../../components/settings/media_source/PlexServerEditDialog.tsx';
import { NumericFormControllerText } from '../../components/util/TypedController.tsx';
import {
deleteApiMediaSourcesByIdMutation,
getApiSettingsMediaSourceOptions,
postApiMediaSourcesByIdLibrariesRefreshMutation,
putApiSettingsMediaSourceMutation,
} from '../../generated/@tanstack/react-query.gen.ts';
import { invalidateTaggedQueries } from '../../helpers/queryUtil.ts';
import { useStoreBackedTableSettings } from '../../hooks/useTableSettings.ts';
@@ -48,9 +37,6 @@ import type { Nullable } from '../../types/util.ts';
export default function MediaSourceSettingsPage() {
const { data: servers } = useMediaSources();
const { data: mediaSourceSettings } = useSuspenseQuery(
getApiSettingsMediaSourceOptions(),
);
const tableState = useStoreBackedTableSettings('MediaSourceSettings');
const [editingMediaSource, setEditingMediaSource] =
@@ -191,48 +177,6 @@ export default function MediaSourceSettingsPage() {
positionActionsColumn: 'last',
});
const snackbar = useSnackbar();
const settingsForm = useForm<GlobalMediaSourceSettings>({
defaultValues: mediaSourceSettings,
});
const updateMediaSourceSettingsMut = useMutation({
...putApiSettingsMediaSourceMutation(),
onSuccess: (returned) => {
settingsForm.reset(returned);
snackbar.enqueueSnackbar({
variant: 'success',
message: 'Successfully updated Media Source settings.',
});
},
onError: (err) => {
console.error(err);
snackbar.enqueueSnackbar({
variant: 'error',
message:
'Failed to update Media Source settings. Please check server and browser logs for details.',
});
},
});
const onSubmit = useCallback(
(data: GlobalMediaSourceSettings) => {
updateMediaSourceSettingsMut.mutate({
body: data,
});
},
[updateMediaSourceSettingsMut],
);
const onError = useCallback(() => {
snackbar.enqueueSnackbar({
variant: 'error',
message:
'There was an error submitting the request to update Media Source settings. Please check the form and try again',
});
}, [snackbar]);
return (
<>
<Stack divider={<Divider />} gap={2}>
@@ -266,33 +210,6 @@ export default function MediaSourceSettingsPage() {
<Box sx={{ display: 'flex', flexWrap: 'wrap', mb: 1 }}></Box>
<MaterialReactTable table={table} />
</Box>
<Box
component="form"
onSubmit={settingsForm.handleSubmit(onSubmit, onError)}
>
<Stack gap={2}>
<Typography variant="h5">Scanner Settings</Typography>
<NumericFormControllerText
control={settingsForm.control}
name="rescanIntervalHours"
prettyFieldName="Rescan Interval (hours)"
TextFieldProps={{
label: 'Rescan Interval (hours)',
helperText:
'How frequently libraries should be scanned (starting from midnight).',
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="submit"
variant="contained"
disabled={!settingsForm.formState.isDirty}
>
Save
</Button>
</Box>
</Stack>
</Box>
</Stack>
{editingMediaSource?.type === 'plex' && (
<PlexServerEditDialog

View File

@@ -0,0 +1,90 @@
import { NumericFormControllerText } from '@/components/util/TypedController.tsx';
import {
getApiSettingsMediaSourceOptions,
putApiSettingsMediaSourceMutation,
} from '@/generated/@tanstack/react-query.gen.ts';
import { Box, Button, Stack } from '@mui/material';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import type { GlobalMediaSourceSettings } from '@tunarr/types/schemas';
import { useSnackbar } from 'notistack';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
export const ScannerSettingsPage = () => {
const { data: mediaSourceSettings } = useSuspenseQuery(
getApiSettingsMediaSourceOptions(),
);
const settingsForm = useForm<GlobalMediaSourceSettings>({
defaultValues: mediaSourceSettings,
});
const snackbar = useSnackbar();
const updateMediaSourceSettingsMut = useMutation({
...putApiSettingsMediaSourceMutation(),
onSuccess: (returned) => {
settingsForm.reset(returned);
snackbar.enqueueSnackbar({
variant: 'success',
message: 'Successfully updated Media Source settings.',
});
},
onError: (err) => {
console.error(err);
snackbar.enqueueSnackbar({
variant: 'error',
message:
'Failed to update Media Source settings. Please check server and browser logs for details.',
});
},
});
const onSubmit = useCallback(
(data: GlobalMediaSourceSettings) => {
updateMediaSourceSettingsMut.mutate({
body: data,
});
},
[updateMediaSourceSettingsMut],
);
const onError = useCallback(() => {
snackbar.enqueueSnackbar({
variant: 'error',
message:
'There was an error submitting the request to update Media Source settings. Please check the form and try again',
});
}, [snackbar]);
return (
<Stack>
<Box
component="form"
onSubmit={settingsForm.handleSubmit(onSubmit, onError)}
>
<Stack gap={2}>
<NumericFormControllerText
control={settingsForm.control}
name="rescanIntervalHours"
prettyFieldName="Rescan Interval (hours)"
TextFieldProps={{
label: 'Rescan Interval (hours)',
helperText:
'How frequently libraries should be scanned (starting from midnight).',
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="submit"
variant="contained"
disabled={!settingsForm.formState.isDirty}
>
Save
</Button>
</Box>
</Stack>
</Box>
</Stack>
);
};

View File

@@ -37,9 +37,9 @@ export function SettingsLayout({ currentTab = '/general' }: Props) {
to="/settings/ffmpeg"
/>
<RouterTabLink
label="Sources"
value="/sources"
to="/settings/sources"
label="Scanner"
value="/scanner"
to="/settings/scanner"
/>
<RouterTabLink label="HDHR" value="/hdhr" to="/settings/hdhr" />
</Tabs>

View File

@@ -24,6 +24,7 @@ import { Route as SystemLogsRouteImport } from './routes/system/logs';
import { Route as SystemDebugRouteImport } from './routes/system/debug';
import { Route as SettingsXmltvRouteImport } from './routes/settings/xmltv';
import { Route as SettingsSourcesRouteImport } from './routes/settings/sources';
import { Route as SettingsScannerRouteImport } from './routes/settings/scanner';
import { Route as SettingsHdhrRouteImport } from './routes/settings/hdhr';
import { Route as SettingsGeneralRouteImport } from './routes/settings/general';
import { Route as SettingsFfmpegRouteImport } from './routes/settings/ffmpeg';
@@ -135,6 +136,11 @@ const SettingsSourcesRoute = SettingsSourcesRouteImport.update({
path: '/sources',
getParentRoute: () => SettingsRoute,
} as any);
const SettingsScannerRoute = SettingsScannerRouteImport.update({
id: '/scanner',
path: '/scanner',
getParentRoute: () => SettingsRoute,
} as any);
const SettingsHdhrRoute = SettingsHdhrRouteImport.update({
id: '/hdhr',
path: '/hdhr',
@@ -346,6 +352,7 @@ export interface FileRoutesByFullPath {
'/settings/ffmpeg': typeof SettingsFfmpegRoute;
'/settings/general': typeof SettingsGeneralRoute;
'/settings/hdhr': typeof SettingsHdhrRoute;
'/settings/scanner': typeof SettingsScannerRoute;
'/settings/sources': typeof SettingsSourcesRoute;
'/settings/xmltv': typeof SettingsXmltvRoute;
'/system/debug': typeof SystemDebugRoute;
@@ -396,6 +403,7 @@ export interface FileRoutesByTo {
'/settings/ffmpeg': typeof SettingsFfmpegRoute;
'/settings/general': typeof SettingsGeneralRoute;
'/settings/hdhr': typeof SettingsHdhrRoute;
'/settings/scanner': typeof SettingsScannerRoute;
'/settings/sources': typeof SettingsSourcesRoute;
'/settings/xmltv': typeof SettingsXmltvRoute;
'/system/debug': typeof SystemDebugRoute;
@@ -447,6 +455,7 @@ export interface FileRoutesById {
'/settings/ffmpeg': typeof SettingsFfmpegRoute;
'/settings/general': typeof SettingsGeneralRoute;
'/settings/hdhr': typeof SettingsHdhrRoute;
'/settings/scanner': typeof SettingsScannerRoute;
'/settings/sources': typeof SettingsSourcesRoute;
'/settings/xmltv': typeof SettingsXmltvRoute;
'/system/debug': typeof SystemDebugRoute;
@@ -501,6 +510,7 @@ export interface FileRouteTypes {
| '/settings/ffmpeg'
| '/settings/general'
| '/settings/hdhr'
| '/settings/scanner'
| '/settings/sources'
| '/settings/xmltv'
| '/system/debug'
@@ -551,6 +561,7 @@ export interface FileRouteTypes {
| '/settings/ffmpeg'
| '/settings/general'
| '/settings/hdhr'
| '/settings/scanner'
| '/settings/sources'
| '/settings/xmltv'
| '/system/debug'
@@ -601,6 +612,7 @@ export interface FileRouteTypes {
| '/settings/ffmpeg'
| '/settings/general'
| '/settings/hdhr'
| '/settings/scanner'
| '/settings/sources'
| '/settings/xmltv'
| '/system/debug'
@@ -773,6 +785,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsSourcesRouteImport;
parentRoute: typeof SettingsRoute;
};
'/settings/scanner': {
id: '/settings/scanner';
path: '/scanner';
fullPath: '/settings/scanner';
preLoaderRoute: typeof SettingsScannerRouteImport;
parentRoute: typeof SettingsRoute;
};
'/settings/hdhr': {
id: '/settings/hdhr';
path: '/hdhr';
@@ -1025,6 +1044,7 @@ interface SettingsRouteChildren {
SettingsFfmpegRoute: typeof SettingsFfmpegRoute;
SettingsGeneralRoute: typeof SettingsGeneralRoute;
SettingsHdhrRoute: typeof SettingsHdhrRoute;
SettingsScannerRoute: typeof SettingsScannerRoute;
SettingsSourcesRoute: typeof SettingsSourcesRoute;
SettingsXmltvRoute: typeof SettingsXmltvRoute;
SettingsFfmpegConfigIdRoute: typeof SettingsFfmpegConfigIdRoute;
@@ -1035,6 +1055,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
SettingsFfmpegRoute: SettingsFfmpegRoute,
SettingsGeneralRoute: SettingsGeneralRoute,
SettingsHdhrRoute: SettingsHdhrRoute,
SettingsScannerRoute: SettingsScannerRoute,
SettingsSourcesRoute: SettingsSourcesRoute,
SettingsXmltvRoute: SettingsXmltvRoute,
SettingsFfmpegConfigIdRoute: SettingsFfmpegConfigIdRoute,

View File

@@ -0,0 +1,16 @@
import {
getApiMediaSourcesOptions,
getApiSettingsMediaSourceOptions,
} from '@/generated/@tanstack/react-query.gen.ts';
import { ScannerSettingsPage } from '@/pages/settings/ScannerSettingsPage.tsx';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/settings/scanner')({
loader: ({ context }) => {
return Promise.all([
context.queryClient.ensureQueryData(getApiMediaSourcesOptions()),
context.queryClient.ensureQueryData(getApiSettingsMediaSourceOptions()),
]);
},
component: ScannerSettingsPage,
});

View File

@@ -1,3 +1,4 @@
import type { Maybe } from '@/types/util.ts';
import { get, isNil, isObject, isUndefined, merge } from 'lodash-es';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
@@ -16,9 +17,11 @@ import {
type ProgrammingListingsState,
createProgrammingListingsState,
} from './programmingSelector/store.ts';
import type { SettingsStateInternal } from './settings/store.ts';
import {
type PersistedSettingsState,
type SettingsState,
SettingsStateInternalSchema,
createSettingsSlice,
} from './settings/store.ts';
import type { ThemeEditorStateInner } from './themeEditor/store.ts';
@@ -75,20 +78,22 @@ const useStore = create<State>()(
'settings',
) as unknown;
// let parsedSettings: Maybe<SettingsStateInternal>;
// if (persistedSettings) {
// const result =
// SettingsStateInternalSchema.safeParse(persistedSettings);
// if (result.error) {
// console.error(
// 'Could not hydrate persisted settings',
// result.error,
// );
// } else {
// parsedSettings = result.data;
// }
// }
// console.log(parsedSettings);
let parsedSettings: Maybe<SettingsStateInternal>;
if (persistedSettings) {
const result = SettingsStateInternalSchema.safeParse(
persistedSettings,
{ reportInput: true },
);
if (result.error) {
console.error(
'Could not hydrate persisted settings',
result.error,
);
} else {
parsedSettings = result.data;
// TODO: provide way to convert to the next version
}
}
// Migrate to new setting.
if (
@@ -113,7 +118,7 @@ const useStore = create<State>()(
settings: merge(
{},
currentState.settings ?? {},
isObject(persistedSettings) ? persistedSettings : {},
parsedSettings ?? {},
),
};
},

View File

@@ -1,4 +1,3 @@
import type { PaginationState } from '@tanstack/react-table';
import type { TupleToUnion } from '@tunarr/types';
import type { DeepPartial } from 'ts-essentials';
import { z } from 'zod';
@@ -8,27 +7,24 @@ import type { StateCreator } from 'zustand';
export const SupportedLocales = ['en', 'en-gb'] as const;
export type SupportedLocales = TupleToUnion<typeof SupportedLocales>;
export interface TableSettings {
pagination: PaginationState;
columnModel: Record<string, boolean>;
}
export const CurrentSettingsSchemaVersion = 1;
const CurrentSettingsSchemaVersion = 1;
const PaginationStateSchema = z.object({
pageIndex: z.int(),
pageSize: z.int(),
});
export const TableSettingsSchema = z.object({
pagination: PaginationStateSchema,
columnModel: z.record(z.string(), z.boolean()),
sortState: z.array(
const TableSettingsSchema = z.object({
pagination: PaginationStateSchema.optional(),
columnModel: z.record(z.string(), z.boolean()).optional(),
sortState: z
.array(
z.object({
desc: z.boolean(),
id: z.string(),
}),
),
)
.optional(),
});
export const SettingsStateInternalSchema = z.object({