diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js
index 00c05c43ec3..024c351c3d1 100644
--- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js
+++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js
@@ -358,6 +358,10 @@ class ElectrophysiologySessionView extends Component {
eegMontage,
} = this.state.database[i];
const file = this.state.database[i].file;
+ const channelsURL = `${loris.BaseURL}/api/v0.0.4-dev/candidates`
+ + `/${this.state.patient.info.pscid}`
+ + `/${this.state.patient.info.visit_label}/recordings/${file.name}`
+ + `/channels`;
const splitPagination = [];
for (const j of Array(file.splitData?.splitCount).keys()) {
splitPagination.push(
@@ -403,6 +407,7 @@ class ElectrophysiologySessionView extends Component {
{EEG_VIS_ENABLED &&
{
}
};
+ fetchJSON(props.channelsURL).then((json: ChannelInfos) => {
+ this.store.dispatch(setDatasetMetadata({
+ bidsChannels: json.Channels,
+ }));
+ });
+
Promise.race(racers(fetchJSON, chunksURL, '/index.json')).then(
({json, url}) => {
if (json) {
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ChannelTypesSelector.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ChannelTypesSelector.tsx
new file mode 100644
index 00000000000..ecb41b4153a
--- /dev/null
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ChannelTypesSelector.tsx
@@ -0,0 +1,48 @@
+import {ChannelTypeState} from "./SeriesRenderer";
+
+/**
+ * Component that displays the list of channel types present in the acquisition and
+ * allows to configure which ones should be displayed or not.
+ */
+const ChannelTypesSelector = ({channelTypes, setChannelTypes}: {
+ channelTypes: Record,
+ setChannelTypes: React.Dispatch>>,
+}) => {
+ return (
+
+ );
+}
+
+export default ChannelTypesSelector;
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Pagination.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Pagination.tsx
new file mode 100644
index 00000000000..8796a5f909f
--- /dev/null
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Pagination.tsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import {useTranslation} from "react-i18next";
+import {CHANNEL_DISPLAY_OPTIONS} from "../../vector";
+import {RightPanel} from "../store/types";
+
+/**
+ * Pagination component that provides controls for selecting how many channels
+ * should be displayed at once, and navigating through the paginated channels.
+ */
+function Pagination({
+ limit,
+ selectedChannelsCount,
+ visibleChannelsCount,
+ offsetIndex,
+ setOffsetIndex,
+ displayedChannelsLimit,
+ setDisplayedChannelsLimit,
+ rightPanel,
+}: {
+ limit: number,
+ selectedChannelsCount: number,
+ visibleChannelsCount: number,
+ offsetIndex: number,
+ setOffsetIndex: (_: number) => void,
+ displayedChannelsLimit: number,
+ setDisplayedChannelsLimit: (_: number) => void,
+ rightPanel: RightPanel,
+}) {
+ const {t} = useTranslation();
+
+ const hardLimit = Math.min(offsetIndex + limit - 1, visibleChannelsCount);
+
+ return (
+
+ );
+}
+
+export default Pagination;
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx
index 39b6ba5e916..62015007c11 100644
--- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx
@@ -5,6 +5,7 @@ import React, {
useRef,
FunctionComponent,
MutableRefObject,
+ useMemo,
} from 'react';
import * as R from 'ramda';
import {vec2} from 'gl-matrix';
@@ -16,7 +17,6 @@ import {colorOrder} from '../../color';
import {
MAX_RENDERED_EPOCHS,
DEFAULT_MAX_CHANNELS,
- CHANNEL_DISPLAY_OPTIONS,
SIGNAL_UNIT,
Vector2,
DEFAULT_TIME_INTERVAL,
@@ -32,11 +32,12 @@ import SeriesCursor from './SeriesCursor';
import LoadingBar from './LoadingBar';
import {setRightPanel} from '../store/state/rightPanel';
import {setDatasetMetadata} from '../store/state/dataset';
-import {setOffsetIndex} from '../store/logic/pagination';
+import {createChannelTypesDict, filterDisplayedChannels, filterSelectedChannels} from '../store/logic/channels';
import IntervalSelect from './IntervalSelect';
import EventManager from './EventManager';
import AnnotationForm from './AnnotationForm';
import {RootState} from '../store';
+import {createAction} from 'redux-actions';
import {
setAmplitudesScale,
@@ -64,6 +65,7 @@ import {
Channel,
Epoch as EpochType,
RightPanel, EpochFilter,
+ ChannelInfo,
} from '../store/types';
import {setCurrentAnnotation} from '../store/state/currentAnnotation';
import {setCursorInteraction} from '../store/logic/cursorInteraction';
@@ -72,6 +74,18 @@ import {getEpochsInRange, updateActiveEpoch} from '../store/logic/filterEpochs';
import HEDEndorsement from "./HEDEndorsement";
import {setTimeSelection} from "../store/state/timeSelection";
import {useTranslation} from "react-i18next";
+import ChannelTypesSelector from './ChannelTypesSelector';
+import Pagination from './Pagination';
+import {SET_CHANNELS} from '../store/state/channels';
+import {UPDATE_VIEWED_CHUNKS} from '../store/logic/fetchChunks';
+
+/**
+ * The state of a channel type.
+ */
+export type ChannelTypeState = {
+ visible: boolean,
+ channelsCount: number,
+}
type CProps = {
ref: MutableRefObject,
@@ -91,8 +105,7 @@ type CProps = {
epochs: EpochType[],
filteredEpochs: EpochFilter,
activeEpoch: number,
- offsetIndex: number,
- setOffsetIndex: (_: number) => void,
+ bidsChannels: ChannelInfo[],
setAmplitudesScale: (_: number) => void,
resetAmplitudesScale: (_: void) => void,
setLowPassFilter: (_: string) => void,
@@ -135,8 +148,7 @@ const SeriesRenderer: FunctionComponent = ({
epochs,
filteredEpochs,
activeEpoch,
- offsetIndex,
- setOffsetIndex,
+ bidsChannels: bidsChannels,
setAmplitudesScale,
resetAmplitudesScale,
setLowPassFilter,
@@ -160,6 +172,10 @@ const SeriesRenderer: FunctionComponent = ({
numDisplayedChannels,
setNumDisplayedChannels,
] = useState(DEFAULT_MAX_CHANNELS);
+
+ // The channel types are indexed by channel type name.
+ const [channelTypes, setChannelTypes] = useState>({});
+
const [cursorEnabled, setCursorEnabled] = useState(false);
const toggleCursor = () => setCursorEnabled((value) => !value);
const [DCOffsetView, setDCOffsetView] = useState(true);
@@ -174,6 +190,7 @@ const SeriesRenderer: FunctionComponent = ({
const [lowPass, setLowPass] = useState('none');
const [refNode, setRefNode] = useState(null);
const [bounds, setBounds] = useState(null);
+ const [offsetIndex, setOffsetIndex] = useState(1);
const getBounds = useCallback((domNode) => {
if (domNode) {
setRefNode(domNode);
@@ -185,6 +202,11 @@ const SeriesRenderer: FunctionComponent = ({
const [eventChannels, setEventChannels] = useState([]);
const {t} = useTranslation();
+ // Initialize the channel types mapping once channels information is loaded.
+ useEffect(() => {
+ setChannelTypes(createChannelTypesDict(channelMetadata, bidsChannels));
+ }, [channelMetadata, bidsChannels]);
+
window.onbeforeunload = function() {
if (panelIsDirty) {
return t(
@@ -526,7 +548,36 @@ const SeriesRenderer: FunctionComponent = ({
.range([topLeft[1], bottomRight[1]]),
];
+ // Selected channels are all the channels that should be currently available in the viewer,
+ // including those not currently displayed because of pagination.
+ const selectedChannels = useMemo(() => (
+ filterSelectedChannels(channelMetadata, bidsChannels, channelTypes)
+ ), [bidsChannels, channelMetadata, channelTypes]);
+
+ // Indexes of the selected channel indexes for comparison with previous renders.
+ const selectedChannelIndexes = JSON.stringify(selectedChannels.map((channel) => channel.index));
+
+ // Displayed channels are all the selected channels that are currently displayed on screen.
+ channels = useMemo(() => (
+ filterDisplayedChannels(selectedChannels, offsetIndex, limit, channels)
+ ), [bidsChannels, selectedChannelIndexes, offsetIndex, limit, channels]);
+
+ // Indexes of the displayed channel indexes for comparison with previous renders.
+ const displayedChannelIndexes = JSON.stringify(channels.map((channel) => channel.index));
+
+ // Hack to update the global store whenever displayed channels are updated.
+ useEffect(() => {
+ const store = window.EEGLabSeriesProviderStore[chunksURL];
+ if (store === undefined) {
+ return;
+ }
+
+ store.dispatch(createAction(SET_CHANNELS)(channels));
+ store.dispatch(createAction(UPDATE_VIEWED_CHUNKS)());
+ }, [displayedChannelIndexes]);
+
const filteredChannels = channels.filter((_, i) => !hidden.includes(i));
+
const showAxisScaleLines = false; // Visibility state of y-axis scale lines
/**
@@ -819,8 +870,6 @@ const SeriesRenderer: FunctionComponent = ({
);
};
- const hardLimit = Math.min(offsetIndex + limit - 1, channelMetadata.length);
-
/**
*
*/
@@ -845,10 +894,9 @@ const SeriesRenderer: FunctionComponent = ({
};
/**
- *
+ * Handle a change to the limit of the number of displayed channels.
*/
- const handleChannelChange = (e) => {
- const numChannels = parseInt(e.target.value, 10);
+ const handleChannelChange = (numChannels: number) => {
setNumDisplayedChannels(numChannels); // This one is the frontend controller
setDatasetMetadata({limit: numChannels}); // Will trigger re-render to the store
setOffsetIndex(offsetIndex); // Will include new channels on limit increase
@@ -959,6 +1007,10 @@ const SeriesRenderer: FunctionComponent = ({
>
{t('Add Event', {ns: 'electrophysiology_browser'})}
+
{
rightPanel === null && (
-
-
+
@@ -1622,7 +1607,6 @@ SeriesRenderer.defaultProps = {
epochs: [],
hidden: [],
channelMetadata: [],
- offsetIndex: 1,
limit: DEFAULT_MAX_CHANNELS,
};
@@ -1636,12 +1620,12 @@ export default connect(
timexSelection: state.timeSelection,
chunksURL: state.dataset.chunksURL,
channels: state.channels,
+ bidsChannels: state.dataset.bidsChannels,
epochs: state.dataset.epochs,
filteredEpochs: state.dataset.filteredEpochs,
activeEpoch: state.dataset.activeEpoch,
hidden: state.montage.hidden,
channelMetadata: state.dataset.channelMetadata,
- offsetIndex: state.dataset.offsetIndex,
limit: state.dataset.limit,
loadedChannels: state.dataset.loadedChannels,
domain: state.bounds.domain,
@@ -1649,10 +1633,6 @@ export default connect(
hoveredChannels: state.cursor.hoveredChannels,
}),
(dispatch: (_: any) => void) => ({
- setOffsetIndex: R.compose(
- dispatch,
- setOffsetIndex
- ),
setInterval: R.compose(
dispatch,
setInterval
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx
index 1d424b56b18..c247636e591 100644
--- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx
@@ -12,7 +12,6 @@ import {channelsReducer} from './state/channels';
import {createDragBoundsEpic} from './logic/dragBounds';
import {createTimeSelectionEpic} from './logic/timeSelection';
import {createFetchChunksEpic} from './logic/fetchChunks';
-import {createPaginationEpic} from './logic/pagination';
import {
createActiveEpochEpic,
createFilterEpochsEpic,
@@ -51,10 +50,6 @@ export const rootEpic = combineEpics(
dataset,
channels,
})),
- createPaginationEpic(({dataset, channels}) => {
- const {limit, channelMetadata} = dataset;
- return {limit, channelMetadata, channels};
- }),
createScaleAmplitudesEpic(({bounds}) => {
const {amplitudeScale} = bounds;
return amplitudeScale;
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/channels.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/channels.tsx
new file mode 100644
index 00000000000..5767c57d566
--- /dev/null
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/channels.tsx
@@ -0,0 +1,109 @@
+import {ChannelTypeState} from '../../components/SeriesRenderer';
+import {Channel, ChannelInfo, ChannelMetadata} from '../types';
+
+/**
+ * Create the channel types dictionary that maps each channel type found in the dataset
+ * to its state.
+ */
+export function createChannelTypesDict(
+ rawChannels: ChannelMetadata[],
+ bidsChannels: ChannelInfo[],
+): Record {
+ const channelTypes: Record = {};
+
+ for (const rawChannel of rawChannels) {
+ const bidsChannel = findBidsChannel(rawChannel, bidsChannels);
+ const channelTypeName = bidsChannel?.ChannelType ?? 'Unknown';
+ if (channelTypes[channelTypeName] === undefined) {
+ channelTypes[channelTypeName] = {
+ visible: true,
+ channelsCount: 0,
+ };
+ }
+
+ channelTypes[channelTypeName].channelsCount++;
+ }
+
+ return channelTypes;
+}
+/**
+ * Filter the list of all channels to keep only those whose channel types are visible.
+ */
+export function filterSelectedChannels(
+ rawChannels: ChannelMetadata[],
+ bidsChannels: ChannelInfo[],
+ channelTypes: Record,
+): ChannelMetadata[] {
+ // If no channel types are loaded, do not filter any channel out.
+ if (Object.keys(channelTypes).length === 0) {
+ return rawChannels;
+ }
+
+ return rawChannels.filter((rawChannel) => {
+ const bidsChannel = findBidsChannel(rawChannel, bidsChannels);
+
+ // If there is a mismatch between the BIDS channels and the raw channels,
+ // display the channel by default.
+ if (bidsChannel === undefined) {
+ return channelTypes['Unknown'].visible;
+ }
+
+ const channelType = channelTypes[bidsChannel.ChannelType];
+ if (channelType === undefined) {
+ return true;
+ }
+
+ return channelTypes[bidsChannel.ChannelType].visible;
+ });
+}
+
+/**
+ * Find the BIDS channel corresponding to a raw channel among a list of BIDS channels.
+ */
+function findBidsChannel(
+ rawChannel: ChannelMetadata,
+ bidsChannels: ChannelInfo[]
+): ChannelInfo | undefined {
+ return bidsChannels.find((bidsChannel) =>
+ bidsChannel.ChannelName === rawChannel.name
+ );
+}
+
+/**
+ * Filter the list of selected channels to keep only those that should be displayed on the current
+ * page.
+ */
+export function filterDisplayedChannels(
+ selectedChannels: ChannelMetadata[],
+ offsetIndex: number,
+ pageLimit: number,
+ previousChannels: Channel[],
+): Channel[] {
+ // Index of of the first displayed channel among the selected channels.
+ let selectedChannelIndex = offsetIndex - 1;
+
+ const maxSelectedChannelIndex = Math.min(
+ offsetIndex + pageLimit - 1,
+ selectedChannels.length
+ );
+
+ const newChannels = [];
+ while (selectedChannelIndex < maxSelectedChannelIndex) {
+ // Get the channel index of the selected channel.
+ const channelIndex = selectedChannels[selectedChannelIndex].index;
+
+ // Re-use previous channels if possible.
+ // TODO: need to handle multiple traces using shapes
+ const channel = previousChannels.find((pastChannel) =>
+ pastChannel.index === channelIndex
+ ) ?? {
+ index: channelIndex,
+ traces: [{chunks: [], type: 'line'}],
+ };
+
+ newChannels.push(channel);
+ selectedChannelIndex++;
+ }
+
+ return newChannels;
+}
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx
index 116127ccf5a..c38bd0514cf 100644
--- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx
@@ -31,9 +31,11 @@ export const loadChunks = (chunksData: FetchedChunks[]) => {
return (dispatch: (_: any) => void) => {
const channels : Channel[] = [];
- const filters: Filter[]
- = window.EEGLabSeriesProviderStore[chunksData[0].chunksURL]
- .getState().filters;
+ const filters: Filter[] = chunksData[0] !== undefined
+ ? window.EEGLabSeriesProviderStore[chunksData[0].chunksURL]
+ .getState().filters
+ : [];
+
for (let index = 0; index < chunksData.length; index++) {
const {channelIndex, chunks} : {
channelIndex: number,
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx
deleted file mode 100644
index 1659e91e4f4..00000000000
--- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import * as R from 'ramda';
-import {Observable} from 'rxjs';
-import * as Rx from 'rxjs/operators';
-import {ofType} from 'redux-observable';
-import {createAction} from 'redux-actions';
-import {Channel, ChannelMetadata} from '../types';
-import {setChannels} from '../state/channels';
-import {setDatasetMetadata} from '../state/dataset';
-import {updateViewedChunks} from './fetchChunks';
-
-export const SET_OFFSET_INDEX = 'SET_OFFSET_INDEX';
-export const setOffsetIndex = createAction(SET_OFFSET_INDEX);
-
-export type Action = (_: (_: any) => void) => void;
-
-export type State = {
- limit: number,
- channelMetadata: ChannelMetadata[],
- channels: Channel[]
-};
-
-/**
- * createPaginationEpic
- *
- * @param {Function} fromState - A function to parse the current state
- * @returns {Observable} - A stream of actions
- */
-export const createPaginationEpic = (fromState: (_: any) => State) => (
- action$: Observable,
- state$: Observable
-): Observable => {
- return action$.pipe(
- ofType(SET_OFFSET_INDEX),
- Rx.map(R.prop('payload')),
- Rx.withLatestFrom(state$),
- Rx.map<[number, State], any>(([payload, state]) => {
- const {limit, channelMetadata, channels} = fromState(state);
-
- const offsetIndex = Math.min(
- Math.max(payload, 1),
- Math.max(channelMetadata.length - limit + 1, 1)
- );
-
- let channelIndex = offsetIndex - 1;
-
- const newChannels = [];
- const hardLimit = Math.min(
- offsetIndex + limit - 1,
- channelMetadata.length
- );
- while (channelIndex < hardLimit) {
- // TODO: need to handle multiple traces using shapes
- const channel =
- channels.find(
- R.pipe(
- R.prop('index'),
- R.equals(channelIndex)
- )
- ) || {
- index: channelIndex,
- traces: [{chunks: [], type: 'line'}],
- };
-
- newChannels.push(channel);
- channelIndex++;
- }
-
- return (dispatch) => {
- dispatch(setDatasetMetadata({offsetIndex}));
- dispatch(setChannels(newChannels));
- dispatch(updateViewedChunks());
- };
- })
- );
-};
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx
index 4b66e3d5ddd..050eacc6d69 100644
--- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx
@@ -1,6 +1,7 @@
import * as R from 'ramda';
import {createAction} from 'redux-actions';
import {
+ ChannelInfo,
ChannelMetadata,
Epoch,
EpochFilter,
@@ -52,6 +53,7 @@ export type Action =
| {
type: 'SET_DATASET_METADATA',
payload: {
+ bidsChannels: ChannelInfo[],
chunksURL: string,
channelNames: string[],
shapes: number[][],
@@ -62,7 +64,6 @@ export type Action =
loadedChannels: number,
samplingFrequency: string,
eegMontageName: string,
- offsetIndex: number,
channelDelimiter: string,
tagsHaveChanges: boolean,
recordingHasHED: boolean,
@@ -70,9 +71,9 @@ export type Action =
};
export type State = {
+ bidsChannels: ChannelInfo[],
chunksURL: string,
channelMetadata: ChannelMetadata[],
- offsetIndex: number,
channelDelimiter: string,
limit: number,
loadedChannels: number,
@@ -104,6 +105,7 @@ export type State = {
*/
export const datasetReducer = (
state: State = {
+ bidsChannels: [],
chunksURL: '',
channelMetadata: [],
epochs: [],
@@ -114,7 +116,6 @@ export const datasetReducer = (
},
activeEpoch: null,
physioFileID: null,
- offsetIndex: 1,
channelDelimiter: '',
limit: DEFAULT_MAX_CHANNELS,
loadedChannels: 0,
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx
index dd757ee8766..c453cccd481 100644
--- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx
+++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx
@@ -16,6 +16,7 @@ export type Trace = {
};
export type ChannelMetadata = {
+ index: number,
name: string,
seriesRange: [number, number]
};
@@ -114,3 +115,41 @@ export type HEDEndorsement = {
EndorsementStatus: EndorsementStatus,
EndorsementTime: string,
}
+
+/**
+ * LORIS EEG API acquisition metadata.
+ */
+export type ChannelInfosMetadata = {
+ CandID: string;
+ Visit: string;
+ File: string;
+}
+
+/**
+ * Channel information extracted from the BIDS `channels.tsv` file and obtained
+ * through the LORIS EEG acquisition channel API.
+ */
+export type ChannelInfo = {
+ ChannelName: string;
+ ChannelDescription: string;
+ ChannelType: string;
+ ChannelTypeDescription: string;
+ ChannelStatus: string;
+ StatusDescription: string;
+ SamplingFrequency: number;
+ LowCutoff: string;
+ HighCutoff: string;
+ ManualFlag: string;
+ Notch: string;
+ Reference: string;
+ Unit: string;
+ ChannelFilePath: string;
+}
+
+/**
+ * LORIS EEG acquisition channels API data.
+ */
+export type ChannelInfos = {
+ Meta: ChannelInfosMetadata;
+ Channels: ChannelInfo[];
+}