sanity-plugin-mux-input 2.9.1 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2144 -1911
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2148 -1915
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/actions/assets.ts +22 -0
- package/src/components/ConfigureApi.tsx +32 -9
- package/src/components/ImportVideosFromMux.tsx +26 -2
- package/src/components/Input.tsx +3 -3
- package/src/components/ResyncMetadata.tsx +201 -0
- package/src/components/VideosBrowser.tsx +10 -2
- package/src/hooks/useImportMuxAssets.ts +3 -3
- package/src/hooks/useMuxAssets.ts +64 -53
- package/src/hooks/useResyncMuxMetadata.ts +143 -0
- package/src/util/assetTitlePlaceholder.ts +31 -0
package/package.json
CHANGED
package/src/actions/assets.ts
CHANGED
|
@@ -47,3 +47,25 @@ export function getAsset(client: SanityClient, assetId: string) {
|
|
|
47
47
|
method: 'GET',
|
|
48
48
|
})
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
export function listAssets(
|
|
52
|
+
client: SanityClient,
|
|
53
|
+
options: {limit?: number; cursor?: string | null}
|
|
54
|
+
) {
|
|
55
|
+
const {dataset} = client.config()
|
|
56
|
+
const query: {limit?: string; cursor?: string} = {}
|
|
57
|
+
|
|
58
|
+
if (options.limit) {
|
|
59
|
+
query.limit = options.limit.toString()
|
|
60
|
+
}
|
|
61
|
+
if (options.cursor) {
|
|
62
|
+
query.cursor = options.cursor
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return client.request<{data: MuxAsset[]; next_cursor?: string | null}>({
|
|
66
|
+
url: `/addons/mux/assets/${dataset}/data/list`,
|
|
67
|
+
withCredentials: true,
|
|
68
|
+
method: 'GET',
|
|
69
|
+
query,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -11,25 +11,31 @@ import {
|
|
|
11
11
|
Text,
|
|
12
12
|
TextInput,
|
|
13
13
|
} from '@sanity/ui'
|
|
14
|
-
import
|
|
14
|
+
import {useCallback, useEffect, useId, useMemo, useRef} from 'react'
|
|
15
15
|
import {clear, preload} from 'suspend-react'
|
|
16
16
|
|
|
17
17
|
import {useClient} from '../hooks/useClient'
|
|
18
18
|
import type {SetDialogState} from '../hooks/useDialogState'
|
|
19
|
+
import {useDialogState} from '../hooks/useDialogState'
|
|
19
20
|
import {useSaveSecrets} from '../hooks/useSaveSecrets'
|
|
21
|
+
import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
|
|
20
22
|
import {useSecretsFormState} from '../hooks/useSecretsFormState'
|
|
21
|
-
import {cacheNs} from '../util/constants'
|
|
23
|
+
import {cacheNs, DIALOGS_Z_INDEX} from '../util/constants'
|
|
22
24
|
import {_id as secretsId} from '../util/readSecrets'
|
|
23
25
|
import type {Secrets} from '../util/types'
|
|
24
26
|
import {Header} from './ConfigureApi.styled'
|
|
25
27
|
import FormField from './FormField'
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
// Props for the dialog component when used with external state management
|
|
30
|
+
export interface ConfigureApiDialogProps {
|
|
28
31
|
setDialogState: SetDialogState
|
|
29
32
|
secrets: Secrets
|
|
30
33
|
}
|
|
34
|
+
|
|
31
35
|
const fieldNames = ['token', 'secretKey', 'enableSignedUrls'] as const
|
|
32
|
-
|
|
36
|
+
|
|
37
|
+
// Internal dialog component that can be used with external state
|
|
38
|
+
export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialogProps) {
|
|
33
39
|
const client = useClient()
|
|
34
40
|
const [state, dispatch] = useSecretsFormState(secrets)
|
|
35
41
|
const hasSecretsInitially = useMemo(() => secrets.token && secrets.secretKey, [secrets])
|
|
@@ -112,13 +118,13 @@ function ConfigureApi({secrets, setDialogState}: Props) {
|
|
|
112
118
|
animate
|
|
113
119
|
id={id}
|
|
114
120
|
onClose={handleClose}
|
|
121
|
+
onClickOutside={handleClose}
|
|
115
122
|
header={<Header />}
|
|
123
|
+
zOffset={DIALOGS_Z_INDEX}
|
|
124
|
+
position="fixed"
|
|
116
125
|
width={1}
|
|
117
|
-
style={{
|
|
118
|
-
maxWidth: '550px',
|
|
119
|
-
}}
|
|
120
126
|
>
|
|
121
|
-
<Box padding={
|
|
127
|
+
<Box padding={3}>
|
|
122
128
|
<form onSubmit={handleSubmit} noValidate>
|
|
123
129
|
<Stack space={4}>
|
|
124
130
|
{!hasSecretsInitially && (
|
|
@@ -224,4 +230,21 @@ function ConfigureApi({secrets, setDialogState}: Props) {
|
|
|
224
230
|
)
|
|
225
231
|
}
|
|
226
232
|
|
|
227
|
-
|
|
233
|
+
// Wrapper component that manages its own dialog state (used in VideosBrowser)
|
|
234
|
+
export default function ConfigureApi() {
|
|
235
|
+
const [dialogOpen, setDialogOpen] = useDialogState()
|
|
236
|
+
const secretDocumentValues = useSecretsDocumentValues()
|
|
237
|
+
|
|
238
|
+
const openDialog = useCallback(() => setDialogOpen('secrets'), [setDialogOpen])
|
|
239
|
+
|
|
240
|
+
if (dialogOpen === 'secrets') {
|
|
241
|
+
return (
|
|
242
|
+
<ConfigureApiDialog
|
|
243
|
+
secrets={secretDocumentValues.value.secrets}
|
|
244
|
+
setDialogState={setDialogOpen}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return <Button mode="bleed" text="Configure plugin" onClick={openDialog} />
|
|
250
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CheckmarkCircleIcon,
|
|
3
|
+
ErrorOutlineIcon,
|
|
4
|
+
InfoOutlineIcon,
|
|
5
|
+
RetrieveIcon,
|
|
6
|
+
RetryIcon,
|
|
7
|
+
} from '@sanity/icons'
|
|
2
8
|
import {
|
|
3
9
|
Box,
|
|
4
10
|
Button,
|
|
@@ -120,7 +126,7 @@ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
|
|
|
120
126
|
<Button
|
|
121
127
|
fontSize={2}
|
|
122
128
|
padding={3}
|
|
123
|
-
mode="
|
|
129
|
+
mode="ghost"
|
|
124
130
|
text="Cancel"
|
|
125
131
|
tone="critical"
|
|
126
132
|
onClick={props.closeDialog}
|
|
@@ -149,6 +155,24 @@ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
|
|
|
149
155
|
}
|
|
150
156
|
>
|
|
151
157
|
<Box padding={3}>
|
|
158
|
+
{/* WARNING: SKIPPED ASSETS WITHOUT PLAYBACK */}
|
|
159
|
+
{props.muxAssets.hasSkippedAssetsWithoutPlayback && (
|
|
160
|
+
<Card tone="caution" marginBottom={5} padding={3} border>
|
|
161
|
+
<Flex align="center" gap={2}>
|
|
162
|
+
<InfoOutlineIcon fontSize={36} />
|
|
163
|
+
<Stack space={2}>
|
|
164
|
+
<Text size={2} weight="semibold">
|
|
165
|
+
Some videos were skipped
|
|
166
|
+
</Text>
|
|
167
|
+
<Text size={1}>
|
|
168
|
+
Videos without playback IDs cannot be imported and have been excluded from the
|
|
169
|
+
list.
|
|
170
|
+
</Text>
|
|
171
|
+
</Stack>
|
|
172
|
+
</Flex>
|
|
173
|
+
</Card>
|
|
174
|
+
)}
|
|
175
|
+
|
|
152
176
|
{/* LOADING ASSETS STATE */}
|
|
153
177
|
{(props.muxAssets.loading || props.assetsInSanityLoading) && (
|
|
154
178
|
<Card tone="primary" marginBottom={5} padding={3} border>
|
package/src/components/Input.tsx
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import {Card} from '@sanity/ui'
|
|
2
2
|
import {memo, Suspense} from 'react'
|
|
3
3
|
|
|
4
|
+
import {useAccessControl} from '../hooks/useAccessControl'
|
|
4
5
|
import {useAssetDocumentValues} from '../hooks/useAssetDocumentValues'
|
|
5
6
|
import {useClient} from '../hooks/useClient'
|
|
6
7
|
import {useDialogState} from '../hooks/useDialogState'
|
|
7
8
|
import {useMuxPolling} from '../hooks/useMuxPolling'
|
|
8
9
|
import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
|
|
9
10
|
import type {MuxInputProps, PluginConfig} from '../util/types'
|
|
10
|
-
import
|
|
11
|
+
import {ConfigureApiDialog} from './ConfigureApi'
|
|
11
12
|
import ErrorBoundaryCard from './ErrorBoundaryCard'
|
|
12
13
|
import {InputFallback} from './Input.styled'
|
|
13
14
|
import Onboard from './Onboard'
|
|
14
15
|
import Uploader from './Uploader'
|
|
15
|
-
import {useAccessControl} from '../hooks/useAccessControl'
|
|
16
16
|
|
|
17
17
|
export interface InputProps extends MuxInputProps {
|
|
18
18
|
config: PluginConfig
|
|
@@ -62,7 +62,7 @@ const Input = (props: InputProps) => {
|
|
|
62
62
|
)}
|
|
63
63
|
|
|
64
64
|
{dialogState === 'secrets' && hasConfigAccess && (
|
|
65
|
-
<
|
|
65
|
+
<ConfigureApiDialog
|
|
66
66
|
setDialogState={setDialogState}
|
|
67
67
|
secrets={secretDocumentValues.value.secrets}
|
|
68
68
|
/>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {CheckmarkCircleIcon, ErrorOutlineIcon, SyncIcon} from '@sanity/icons'
|
|
2
|
+
import {Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text} from '@sanity/ui'
|
|
3
|
+
|
|
4
|
+
import useResyncMuxMetadata from '../hooks/useResyncMuxMetadata'
|
|
5
|
+
import {isEmptyOrPlaceholderTitle} from '../util/assetTitlePlaceholder'
|
|
6
|
+
import {DIALOGS_Z_INDEX} from '../util/constants'
|
|
7
|
+
|
|
8
|
+
// eslint-disable-next-line complexity
|
|
9
|
+
function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
|
|
10
|
+
const {resyncState} = props
|
|
11
|
+
|
|
12
|
+
const canTriggerResync = resyncState === 'idle' || resyncState === 'error'
|
|
13
|
+
const isResyncing = resyncState === 'syncing'
|
|
14
|
+
const isDone = resyncState === 'done'
|
|
15
|
+
|
|
16
|
+
const videosToUpdate = props.matchedAssets?.filter((m) => m.muxAsset).length || 0
|
|
17
|
+
const videosWithEmptyOrPlaceholder =
|
|
18
|
+
props.matchedAssets?.filter(
|
|
19
|
+
(m) => m.muxAsset && m.muxTitle && isEmptyOrPlaceholderTitle(m.currentTitle, m.muxAsset.id)
|
|
20
|
+
).length || 0
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Dialog
|
|
24
|
+
animate
|
|
25
|
+
header={'Resync Metadata from Mux'}
|
|
26
|
+
zOffset={DIALOGS_Z_INDEX}
|
|
27
|
+
id="resync-metadata-dialog"
|
|
28
|
+
onClose={props.closeDialog}
|
|
29
|
+
onClickOutside={props.closeDialog}
|
|
30
|
+
width={1}
|
|
31
|
+
position="fixed"
|
|
32
|
+
footer={
|
|
33
|
+
!isDone && (
|
|
34
|
+
<Card padding={3}>
|
|
35
|
+
<Flex justify="space-between" align="center">
|
|
36
|
+
<Button
|
|
37
|
+
fontSize={2}
|
|
38
|
+
padding={3}
|
|
39
|
+
mode="ghost"
|
|
40
|
+
text="Cancel"
|
|
41
|
+
tone="critical"
|
|
42
|
+
onClick={props.closeDialog}
|
|
43
|
+
disabled={isResyncing}
|
|
44
|
+
/>
|
|
45
|
+
<Flex gap={2}>
|
|
46
|
+
{videosWithEmptyOrPlaceholder > 0 && (
|
|
47
|
+
<Button
|
|
48
|
+
fontSize={2}
|
|
49
|
+
padding={3}
|
|
50
|
+
mode="ghost"
|
|
51
|
+
text={`Update empty (${videosWithEmptyOrPlaceholder})`}
|
|
52
|
+
tone="caution"
|
|
53
|
+
onClick={props.syncOnlyEmpty}
|
|
54
|
+
disabled={isResyncing || !canTriggerResync}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
<Button
|
|
58
|
+
icon={SyncIcon}
|
|
59
|
+
fontSize={2}
|
|
60
|
+
padding={3}
|
|
61
|
+
mode="ghost"
|
|
62
|
+
text={`Update all (${videosToUpdate})`}
|
|
63
|
+
tone="positive"
|
|
64
|
+
onClick={props.syncAllVideos}
|
|
65
|
+
iconRight={isResyncing && Spinner}
|
|
66
|
+
disabled={!canTriggerResync}
|
|
67
|
+
/>
|
|
68
|
+
</Flex>
|
|
69
|
+
</Flex>
|
|
70
|
+
</Card>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
<Box padding={4}>
|
|
75
|
+
{/* LOADING ASSETS STATE */}
|
|
76
|
+
{(props.muxAssets.loading || props.sanityAssetsLoading) && (
|
|
77
|
+
<Card tone="primary" marginBottom={5} padding={3} border>
|
|
78
|
+
<Flex align="center" gap={4}>
|
|
79
|
+
<Spinner muted size={4} />
|
|
80
|
+
<Stack space={2}>
|
|
81
|
+
<Text size={2} weight="semibold">
|
|
82
|
+
Loading assets from Mux
|
|
83
|
+
</Text>
|
|
84
|
+
<Text size={1}>This may take a while.</Text>
|
|
85
|
+
</Stack>
|
|
86
|
+
</Flex>
|
|
87
|
+
</Card>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* ERROR LOADING MUX */}
|
|
91
|
+
{props.muxAssets.error && (
|
|
92
|
+
<Card tone="critical" marginBottom={5} padding={3} border>
|
|
93
|
+
<Flex align="center" gap={2}>
|
|
94
|
+
<ErrorOutlineIcon fontSize={36} />
|
|
95
|
+
<Stack space={2}>
|
|
96
|
+
<Text size={2} weight="semibold">
|
|
97
|
+
There was an error getting data from Mux
|
|
98
|
+
</Text>
|
|
99
|
+
<Text size={1}>Please try again or contact a developer for help.</Text>
|
|
100
|
+
</Stack>
|
|
101
|
+
</Flex>
|
|
102
|
+
</Card>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{/* SYNCING STATE */}
|
|
106
|
+
{resyncState === 'syncing' && (
|
|
107
|
+
<Card tone="primary" marginBottom={5} padding={3} border>
|
|
108
|
+
<Flex align="center" gap={4}>
|
|
109
|
+
<Spinner muted size={4} />
|
|
110
|
+
<Stack space={2}>
|
|
111
|
+
<Text size={2} weight="semibold">
|
|
112
|
+
Updating video metadata
|
|
113
|
+
</Text>
|
|
114
|
+
<Text size={1}>Syncing titles from Mux...</Text>
|
|
115
|
+
</Stack>
|
|
116
|
+
</Flex>
|
|
117
|
+
</Card>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* ERROR SYNCING */}
|
|
121
|
+
{resyncState === 'error' && (
|
|
122
|
+
<Card tone="critical" marginBottom={5} padding={3} border>
|
|
123
|
+
<Flex align="center" gap={2}>
|
|
124
|
+
<ErrorOutlineIcon fontSize={36} />
|
|
125
|
+
<Stack space={2}>
|
|
126
|
+
<Text size={2} weight="semibold">
|
|
127
|
+
There was an error syncing metadata
|
|
128
|
+
</Text>
|
|
129
|
+
<Text size={1}>
|
|
130
|
+
{props.resyncError
|
|
131
|
+
? `Error: ${props.resyncError}`
|
|
132
|
+
: 'Please try again or contact a developer for help.'}
|
|
133
|
+
</Text>
|
|
134
|
+
</Stack>
|
|
135
|
+
</Flex>
|
|
136
|
+
</Card>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{/* SUCCESS STATE */}
|
|
140
|
+
{resyncState === 'done' && (
|
|
141
|
+
<Stack paddingY={5} marginBottom={4} space={3} style={{textAlign: 'center'}}>
|
|
142
|
+
<Box>
|
|
143
|
+
<CheckmarkCircleIcon fontSize={48} />
|
|
144
|
+
</Box>
|
|
145
|
+
<Heading size={2}>Metadata synced successfully</Heading>
|
|
146
|
+
<Text size={2}>All video titles have been updated from Mux.</Text>
|
|
147
|
+
</Stack>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* CONFIRMATION MESSAGE */}
|
|
151
|
+
{resyncState === 'idle' && !props.muxAssets.loading && !props.sanityAssetsLoading && (
|
|
152
|
+
<Stack space={4}>
|
|
153
|
+
<Heading size={1}>
|
|
154
|
+
There {videosToUpdate === 1 ? 'is' : 'are'} {videosToUpdate} video
|
|
155
|
+
{videosToUpdate === 1 ? '' : 's'} with Mux metadata
|
|
156
|
+
</Heading>
|
|
157
|
+
<Text size={2}>
|
|
158
|
+
This will update video titles in Sanity to match those in Mux. No new videos will be
|
|
159
|
+
created.
|
|
160
|
+
</Text>
|
|
161
|
+
{videosWithEmptyOrPlaceholder > 0 && (
|
|
162
|
+
<Card padding={3} tone="caution" border>
|
|
163
|
+
<Flex align="flex-start" gap={2}>
|
|
164
|
+
<Box>
|
|
165
|
+
<ErrorOutlineIcon />
|
|
166
|
+
</Box>
|
|
167
|
+
<Stack space={2}>
|
|
168
|
+
<Text size={2} weight="semibold">
|
|
169
|
+
Videos with empty or placeholder titles
|
|
170
|
+
</Text>
|
|
171
|
+
<Text size={1} muted>
|
|
172
|
+
{videosWithEmptyOrPlaceholder} video
|
|
173
|
+
{videosWithEmptyOrPlaceholder === 1 ? '' : 's'} without titles or with
|
|
174
|
+
placeholder titles (e.g., "Asset #123") can be updated selectively.
|
|
175
|
+
</Text>
|
|
176
|
+
</Stack>
|
|
177
|
+
</Flex>
|
|
178
|
+
</Card>
|
|
179
|
+
)}
|
|
180
|
+
</Stack>
|
|
181
|
+
)}
|
|
182
|
+
</Box>
|
|
183
|
+
</Dialog>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default function ResyncMetadata() {
|
|
188
|
+
const resyncMetadata = useResyncMuxMetadata()
|
|
189
|
+
|
|
190
|
+
if (!resyncMetadata.hasSecrets) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (resyncMetadata.dialogOpen) {
|
|
195
|
+
// eslint-disable-next-line consistent-return
|
|
196
|
+
return <ResyncMetadataDialog {...resyncMetadata} />
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// eslint-disable-next-line consistent-return
|
|
200
|
+
return <Button mode="bleed" text="Resync Metadata" onClick={resyncMetadata.openDialog} />
|
|
201
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {SearchIcon} from '@sanity/icons'
|
|
2
|
-
import {Card, Flex, Grid, Label, Stack, Text, TextInput} from '@sanity/ui'
|
|
2
|
+
import {Card, Flex, Grid, Inline, Label, Stack, Text, TextInput} from '@sanity/ui'
|
|
3
3
|
import {useMemo, useState} from 'react'
|
|
4
4
|
|
|
5
5
|
import useAssets from '../hooks/useAssets'
|
|
6
6
|
import type {VideoAssetDocument} from '../util/types'
|
|
7
|
+
import ConfigureApi from './ConfigureApi'
|
|
7
8
|
import ImportVideosFromMux from './ImportVideosFromMux'
|
|
9
|
+
import ResyncMetadata from './ResyncMetadata'
|
|
8
10
|
import {SelectSortOptions} from './SelectSortOptions'
|
|
9
11
|
import SpinnerBox from './SpinnerBox'
|
|
10
12
|
import type {VideoDetailsProps} from './VideoDetails/useVideoDetails'
|
|
@@ -39,7 +41,13 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
|
|
|
39
41
|
/>
|
|
40
42
|
<SelectSortOptions setSort={setSort} sort={sort} />
|
|
41
43
|
</Flex>
|
|
42
|
-
{placement === 'tool' &&
|
|
44
|
+
{placement === 'tool' && (
|
|
45
|
+
<Inline space={2}>
|
|
46
|
+
<ImportVideosFromMux />
|
|
47
|
+
<ResyncMetadata />
|
|
48
|
+
<ConfigureApi />
|
|
49
|
+
</Inline>
|
|
50
|
+
)}
|
|
43
51
|
</Flex>
|
|
44
52
|
<Stack space={3}>
|
|
45
53
|
{assets?.length > 0 && (
|
|
@@ -3,11 +3,11 @@ import {useMemo, useState} from 'react'
|
|
|
3
3
|
import {
|
|
4
4
|
createHookFromObservableFactory,
|
|
5
5
|
type DocumentStore,
|
|
6
|
-
truncateString,
|
|
7
6
|
useClient,
|
|
8
7
|
useDocumentStore,
|
|
9
8
|
} from 'sanity'
|
|
10
9
|
|
|
10
|
+
import {generateAssetPlaceholder} from '../util/assetTitlePlaceholder'
|
|
11
11
|
import {parseMuxDate} from '../util/parsers'
|
|
12
12
|
import type {MuxAsset, VideoAssetDocument} from '../util/types'
|
|
13
13
|
import {SANITY_API_VERSION} from './useClient'
|
|
@@ -37,7 +37,7 @@ export default function useImportMuxAssets() {
|
|
|
37
37
|
const dialogOpen = importState !== 'closed'
|
|
38
38
|
|
|
39
39
|
const muxAssets = useMuxAssets({
|
|
40
|
-
|
|
40
|
+
client,
|
|
41
41
|
enabled: hasSecrets && dialogOpen,
|
|
42
42
|
})
|
|
43
43
|
|
|
@@ -101,7 +101,7 @@ function muxAssetToSanityDocument(asset: MuxAsset): VideoAssetDocument | undefin
|
|
|
101
101
|
_createdAt: parseMuxDate(asset.created_at).toISOString(),
|
|
102
102
|
assetId: asset.id,
|
|
103
103
|
playbackId,
|
|
104
|
-
filename: asset.meta?.title ??
|
|
104
|
+
filename: asset.meta?.title ?? generateAssetPlaceholder(asset.id),
|
|
105
105
|
status: asset.status,
|
|
106
106
|
data: asset,
|
|
107
107
|
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import {useEffect, useState} from 'react'
|
|
2
2
|
import {defer, of, timer} from 'rxjs'
|
|
3
3
|
import {concatMap, expand, tap} from 'rxjs/operators'
|
|
4
|
+
import type {SanityClient} from 'sanity'
|
|
4
5
|
|
|
5
|
-
import
|
|
6
|
+
import {listAssets} from '../actions/assets'
|
|
7
|
+
import type {MuxAsset} from '../util/types'
|
|
6
8
|
|
|
7
|
-
const FIRST_PAGE = 1
|
|
8
9
|
const ASSETS_PER_PAGE = 100
|
|
9
10
|
|
|
10
11
|
type MuxAssetsState = {
|
|
11
|
-
|
|
12
|
+
cursor: string | null
|
|
12
13
|
loading: boolean
|
|
13
14
|
data?: MuxAsset[]
|
|
14
15
|
error?: FetchError
|
|
16
|
+
hasSkippedAssetsWithoutPlayback?: boolean
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
type FetchError =
|
|
@@ -23,49 +25,36 @@ type FetchError =
|
|
|
23
25
|
type PageResult = (
|
|
24
26
|
| {
|
|
25
27
|
data: MuxAsset[]
|
|
28
|
+
next_cursor: string | null
|
|
26
29
|
}
|
|
27
30
|
| {
|
|
28
31
|
error: FetchError
|
|
29
32
|
}
|
|
30
33
|
) & {
|
|
31
|
-
|
|
34
|
+
cursor: string | null
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* @docs {@link https://docs.mux.com/api-reference#video/operation/list-assets}
|
|
36
39
|
*/
|
|
37
40
|
async function fetchMuxAssetsPage(
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
client: SanityClient,
|
|
42
|
+
cursor: string | null
|
|
40
43
|
): Promise<PageResult> {
|
|
41
44
|
try {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Authorization: `Basic ${btoa(`${token}:${secretKey}`)}`,
|
|
47
|
-
},
|
|
48
|
-
}
|
|
49
|
-
)
|
|
50
|
-
const json = await res.json()
|
|
51
|
-
|
|
52
|
-
if (json.error) {
|
|
53
|
-
return {
|
|
54
|
-
pageNum,
|
|
55
|
-
error: {
|
|
56
|
-
_tag: 'MuxError',
|
|
57
|
-
error: json.error,
|
|
58
|
-
},
|
|
59
|
-
}
|
|
60
|
-
}
|
|
45
|
+
const response = await listAssets(client, {
|
|
46
|
+
limit: ASSETS_PER_PAGE,
|
|
47
|
+
cursor,
|
|
48
|
+
})
|
|
61
49
|
|
|
62
50
|
return {
|
|
63
|
-
|
|
64
|
-
data:
|
|
51
|
+
cursor,
|
|
52
|
+
data: response.data as MuxAsset[],
|
|
53
|
+
next_cursor: response.next_cursor || null,
|
|
65
54
|
}
|
|
66
55
|
} catch (error) {
|
|
67
56
|
return {
|
|
68
|
-
|
|
57
|
+
cursor,
|
|
69
58
|
error: {_tag: 'FetchError'},
|
|
70
59
|
}
|
|
71
60
|
}
|
|
@@ -76,66 +65,88 @@ function accumulateIntermediateState(
|
|
|
76
65
|
pageResult: PageResult
|
|
77
66
|
): MuxAssetsState {
|
|
78
67
|
const currentData = ('data' in currentState && currentState.data) || []
|
|
68
|
+
const newAssets = ('data' in pageResult && pageResult.data) || []
|
|
69
|
+
|
|
70
|
+
// Filter assets and check for skipped items
|
|
71
|
+
const {validAssets, skippedInThisPage} = newAssets.reduce<{
|
|
72
|
+
validAssets: MuxAsset[]
|
|
73
|
+
skippedInThisPage: boolean
|
|
74
|
+
}>(
|
|
75
|
+
(acc, asset) => {
|
|
76
|
+
const hasPlaybackIds = asset.playback_ids && asset.playback_ids.length > 0
|
|
77
|
+
const isDuplicate = currentData.some((a) => a.id === asset.id)
|
|
78
|
+
|
|
79
|
+
if (!hasPlaybackIds) {
|
|
80
|
+
acc.skippedInThisPage = true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (hasPlaybackIds && !isDuplicate) {
|
|
84
|
+
acc.validAssets.push(asset)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return acc
|
|
88
|
+
},
|
|
89
|
+
{validAssets: [], skippedInThisPage: false}
|
|
90
|
+
)
|
|
91
|
+
|
|
79
92
|
return {
|
|
80
93
|
...currentState,
|
|
81
|
-
data: [
|
|
82
|
-
...currentData,
|
|
83
|
-
...(('data' in pageResult && pageResult.data) || []).filter(
|
|
84
|
-
// De-duplicate assets for safety
|
|
85
|
-
(asset) => !currentData.some((a) => a.id === asset.id)
|
|
86
|
-
),
|
|
87
|
-
],
|
|
94
|
+
data: [...currentData, ...validAssets],
|
|
88
95
|
error:
|
|
89
96
|
'error' in pageResult
|
|
90
97
|
? pageResult.error
|
|
91
98
|
: // Reset error if current page is successful
|
|
92
99
|
undefined,
|
|
93
|
-
|
|
100
|
+
cursor: 'next_cursor' in pageResult ? pageResult.next_cursor : pageResult.cursor,
|
|
94
101
|
loading: true,
|
|
102
|
+
hasSkippedAssetsWithoutPlayback:
|
|
103
|
+
currentState.hasSkippedAssetsWithoutPlayback || skippedInThisPage,
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
function hasMorePages(pageResult: PageResult) {
|
|
99
108
|
return (
|
|
100
|
-
typeof pageResult === 'object' &&
|
|
101
|
-
'data' in pageResult &&
|
|
102
|
-
Array.isArray(pageResult.data) &&
|
|
103
|
-
pageResult.data.length > 0
|
|
109
|
+
typeof pageResult === 'object' && 'next_cursor' in pageResult && pageResult.next_cursor !== null
|
|
104
110
|
)
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
/**
|
|
108
114
|
* Fetches all assets from a Mux environment. Rules:
|
|
109
115
|
* - One page at a time
|
|
110
|
-
* -
|
|
111
|
-
* - We've finished fetching
|
|
116
|
+
* - Uses cursor-based pagination
|
|
117
|
+
* - We've finished fetching when `next_cursor` is null
|
|
112
118
|
* - Rate limiting to one request per 2 seconds
|
|
113
119
|
* - Update state while still fetching to give feedback to users
|
|
114
120
|
*/
|
|
115
|
-
export default function useMuxAssets({
|
|
116
|
-
const [state, setState] = useState<MuxAssetsState>({loading: true,
|
|
121
|
+
export default function useMuxAssets({client, enabled}: {client: SanityClient; enabled: boolean}) {
|
|
122
|
+
const [state, setState] = useState<MuxAssetsState>({loading: true, cursor: null})
|
|
117
123
|
|
|
118
124
|
useEffect(() => {
|
|
119
125
|
if (!enabled) return
|
|
120
126
|
|
|
121
127
|
const subscription = defer(() =>
|
|
122
128
|
fetchMuxAssetsPage(
|
|
123
|
-
|
|
124
|
-
// When we've already successfully loaded before (fully or partially), we start from the
|
|
125
|
-
'data' in state && state.data && state.data.length > 0 && !state.error
|
|
126
|
-
? state.pageNum + 1
|
|
127
|
-
: state.pageNum
|
|
129
|
+
client,
|
|
130
|
+
// When we've already successfully loaded before (fully or partially), we start from the next cursor to avoid re-fetching
|
|
131
|
+
'data' in state && state.data && state.data.length > 0 && !state.error ? state.cursor : null
|
|
128
132
|
)
|
|
129
133
|
)
|
|
130
134
|
.pipe(
|
|
131
|
-
// Here we
|
|
135
|
+
// Here we use "expand" to recursively fetch next pages
|
|
132
136
|
expand((pageResult) => {
|
|
133
|
-
// if fetched page has
|
|
137
|
+
// if fetched page has next_cursor, we continue emitting, requesting the next page
|
|
134
138
|
// after 2s to avoid rate limiting
|
|
135
139
|
if (hasMorePages(pageResult)) {
|
|
136
140
|
return timer(2000).pipe(
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
concatMap(() =>
|
|
142
|
+
// eslint-disable-next-line max-nested-callbacks
|
|
143
|
+
defer(() =>
|
|
144
|
+
fetchMuxAssetsPage(
|
|
145
|
+
client,
|
|
146
|
+
'next_cursor' in pageResult ? pageResult.next_cursor : null
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
)
|
|
139
150
|
)
|
|
140
151
|
}
|
|
141
152
|
|