sanity-plugin-mux-input 2.9.1 → 2.10.1

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.
@@ -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 type {MuxAsset, Secrets} from '../util/types'
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
- pageNum: number
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
- pageNum: number
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
- {secretKey, token}: Secrets,
39
- pageNum: number
41
+ client: SanityClient,
42
+ cursor: string | null
40
43
  ): Promise<PageResult> {
41
44
  try {
42
- const res = await fetch(
43
- `https://api.mux.com/video/v1/assets?limit=${ASSETS_PER_PAGE}&page=${pageNum}`,
44
- {
45
- headers: {
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
- pageNum,
64
- data: json.data as MuxAsset[],
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
- pageNum,
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
- pageNum: pageResult.pageNum,
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
- * - Mux has no information on pagination
111
- * - We've finished fetching if a page returns `data.length === 0`
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({secrets, enabled}: {enabled: boolean; secrets: Secrets}) {
116
- const [state, setState] = useState<MuxAssetsState>({loading: true, pageNum: FIRST_PAGE})
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
- secrets,
124
- // When we've already successfully loaded before (fully or partially), we start from the following page to avoid re-fetching
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 replace "concatMap" with "expand" to recursively fetch next pages
135
+ // Here we use "expand" to recursively fetch next pages
132
136
  expand((pageResult) => {
133
- // if fetched page has data, we continue emitting, requesting the next page
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
- // eslint-disable-next-line max-nested-callbacks
138
- concatMap(() => defer(() => fetchMuxAssetsPage(secrets, pageResult.pageNum + 1)))
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
 
@@ -0,0 +1,143 @@
1
+ import {useMemo, useState} from 'react'
2
+ import {
3
+ createHookFromObservableFactory,
4
+ type DocumentStore,
5
+ useClient,
6
+ useDocumentStore,
7
+ } from 'sanity'
8
+
9
+ import {isEmptyOrPlaceholderTitle} from '../util/assetTitlePlaceholder'
10
+ import type {MuxAsset, VideoAssetDocument} from '../util/types'
11
+ import {SANITY_API_VERSION} from './useClient'
12
+ import useMuxAssets from './useMuxAssets'
13
+ import {useSecretsDocumentValues} from './useSecretsDocumentValues'
14
+
15
+ type ResyncState = 'closed' | 'idle' | 'syncing' | 'done' | 'error'
16
+
17
+ export type MatchedAsset = {
18
+ sanityDoc: VideoAssetDocument
19
+ muxAsset: MuxAsset | undefined
20
+ muxTitle: string | undefined
21
+ currentTitle: string | undefined
22
+ }
23
+
24
+ export default function useResyncMuxMetadata() {
25
+ const documentStore = useDocumentStore()
26
+ const client = useClient({
27
+ apiVersion: SANITY_API_VERSION,
28
+ })
29
+
30
+ const [sanityAssets, sanityAssetsLoading] = useSanityAssets(documentStore)
31
+
32
+ const secretDocumentValues = useSecretsDocumentValues()
33
+ const hasSecrets = !!secretDocumentValues.value.secrets?.secretKey
34
+
35
+ const [resyncError, setResyncError] = useState<unknown>()
36
+ const [resyncState, setResyncState] = useState<ResyncState>('closed')
37
+ const dialogOpen = resyncState !== 'closed'
38
+
39
+ const muxAssets = useMuxAssets({
40
+ client,
41
+ enabled: hasSecrets && dialogOpen,
42
+ })
43
+
44
+ const matchedAssets = useMemo(() => {
45
+ return sanityAssets && muxAssets.data
46
+ ? sanityAssets.map((sanityDoc) => {
47
+ const muxAsset = muxAssets.data?.find(
48
+ (m) => m.id === sanityDoc.assetId || m.id === sanityDoc.data?.id
49
+ )
50
+ return {
51
+ sanityDoc,
52
+ muxAsset,
53
+ muxTitle: muxAsset?.meta?.title,
54
+ currentTitle: sanityDoc.filename,
55
+ }
56
+ })
57
+ : undefined
58
+ }, [sanityAssets, muxAssets.data])
59
+
60
+ const closeDialog = () => {
61
+ if (resyncState !== 'syncing') setResyncState('closed')
62
+ }
63
+
64
+ const openDialog = () => {
65
+ if (resyncState === 'closed') setResyncState('idle')
66
+ }
67
+
68
+ async function syncAllVideos() {
69
+ if (!matchedAssets) return
70
+
71
+ setResyncState('syncing')
72
+
73
+ try {
74
+ const tx = client.transaction()
75
+
76
+ matchedAssets.forEach((matched) => {
77
+ // Update all videos with the Mux title, even if it's undefined/empty
78
+ const newTitle = matched.muxTitle || ''
79
+ tx.patch(matched.sanityDoc._id, {set: {filename: newTitle}})
80
+ })
81
+
82
+ await tx.commit({returnDocuments: false})
83
+ setResyncState('done')
84
+ } catch (error) {
85
+ setResyncState('error')
86
+ setResyncError(error)
87
+ }
88
+ }
89
+
90
+ async function syncOnlyEmpty() {
91
+ if (!matchedAssets) return
92
+
93
+ setResyncState('syncing')
94
+
95
+ try {
96
+ const tx = client.transaction()
97
+
98
+ matchedAssets.forEach((matched) => {
99
+ // Only update if the current title is empty or has the placeholder format
100
+ // AND there's a new title available from Mux
101
+ if (
102
+ matched.muxAsset &&
103
+ matched.muxTitle &&
104
+ isEmptyOrPlaceholderTitle(matched.currentTitle, matched.muxAsset.id)
105
+ ) {
106
+ tx.patch(matched.sanityDoc._id, {set: {filename: matched.muxTitle}})
107
+ }
108
+ })
109
+
110
+ await tx.commit({returnDocuments: false})
111
+ setResyncState('done')
112
+ } catch (error) {
113
+ setResyncState('error')
114
+ setResyncError(error)
115
+ }
116
+ }
117
+
118
+ return {
119
+ sanityAssetsLoading,
120
+ closeDialog,
121
+ dialogOpen,
122
+ resyncState,
123
+ resyncError,
124
+ hasSecrets,
125
+ syncAllVideos,
126
+ syncOnlyEmpty,
127
+ matchedAssets,
128
+ muxAssets,
129
+ openDialog,
130
+ }
131
+ }
132
+
133
+ const useSanityAssets = createHookFromObservableFactory<VideoAssetDocument[], DocumentStore>(
134
+ (documentStore) => {
135
+ return documentStore.listenQuery(
136
+ /* groq */ `*[_type == "mux.videoAsset"]`,
137
+ {},
138
+ {
139
+ apiVersion: SANITY_API_VERSION,
140
+ }
141
+ )
142
+ }
143
+ )
package/src/schema.ts CHANGED
@@ -98,6 +98,10 @@ const muxAssetData = {
98
98
  type: 'string',
99
99
  name: 'encoding_tier',
100
100
  },
101
+ {
102
+ type: 'string',
103
+ name: 'video_quality',
104
+ },
101
105
  {
102
106
  type: 'string',
103
107
  name: 'master_access',
@@ -0,0 +1,31 @@
1
+ import {truncateString} from 'sanity'
2
+
3
+ /**
4
+ * Generates a placeholder title for a Mux asset when no title is available.
5
+ * This format is used when importing assets that don't have a title in Mux.
6
+ *
7
+ * @param assetId - The Mux asset ID
8
+ * @returns A placeholder title in the format "Asset #[truncated-id]"
9
+ */
10
+ export function generateAssetPlaceholder(assetId: string): string {
11
+ return `Asset #${truncateString(assetId, 15)}`
12
+ }
13
+
14
+ /**
15
+ * Checks if a filename is empty or has the placeholder format.
16
+ * This is used to determine if a video title should be updated during metadata sync.
17
+ *
18
+ * @param filename - The current filename/title of the video
19
+ * @param assetId - The Mux asset ID to check against
20
+ * @returns true if the filename is empty or matches the placeholder format
21
+ */
22
+ export function isEmptyOrPlaceholderTitle(filename: string | undefined, assetId: string): boolean {
23
+ // Check if filename is empty/undefined
24
+ if (!filename || filename.trim() === '') {
25
+ return true
26
+ }
27
+
28
+ // Check if filename matches the placeholder format for this asset
29
+ const placeholder = generateAssetPlaceholder(assetId)
30
+ return filename === placeholder
31
+ }
package/src/util/types.ts CHANGED
@@ -4,7 +4,7 @@ import type {PartialDeep} from 'type-fest'
4
4
  export interface MuxInputConfig {
5
5
  /**
6
6
  * Enable static renditions by setting this to 'standard'. Can be overwritten on a per-asset basis.
7
- * Requires `"encoding_tier": "smart"`
7
+ * Requires `"video_quality": "plus"`
8
8
  * @see {@link https://docs.mux.com/guides/video/enable-static-mp4-renditions#why-enable-mp4-support}
9
9
  * @defaultValue 'none'
10
10
  */
@@ -12,18 +12,27 @@ export interface MuxInputConfig {
12
12
 
13
13
  /**
14
14
  * Max resolution tier can be used to control the maximum resolution_tier your asset is encoded, stored, and streamed at.
15
- * Requires `"encoding_tier": "smart"`
15
+ * Requires `"video_quality": "plus"`
16
16
  * @see {@link https://docs.mux.com/guides/stream-videos-in-4k}
17
17
  * @defaultValue '1080p'
18
18
  */
19
19
  max_resolution_tier: '2160p' | '1440p' | '1080p'
20
20
 
21
21
  /**
22
+ * @deprecated Use {@link video_quality}
23
+ * <br>
22
24
  * The encoding tier informs the cost, quality, and available platform features for the asset.
23
25
  * @see {@link https://docs.mux.com/guides/use-encoding-tiers}
24
26
  * @defaultValue 'smart'
25
27
  */
26
- encoding_tier: 'baseline' | 'smart'
28
+ encoding_tier?: 'baseline' | 'smart'
29
+
30
+ /**
31
+ * The video quality level informs the cost, quality, and available platform features for the asset.
32
+ * @see {@link https://www.mux.com/docs/guides/use-video-quality-levels}
33
+ * @defaultValue 'plus'
34
+ */
35
+ video_quality: 'basic' | 'plus' | 'premium'
27
36
 
28
37
  /**
29
38
  * Normalize the audio track loudness level.
@@ -41,7 +50,7 @@ export interface MuxInputConfig {
41
50
 
42
51
  /**
43
52
  * Auto-generate captions for these languages by default.
44
- * Requires `"encoding_tier": "smart"`
53
+ * Requires `"video_quality": "plus"`
45
54
  *
46
55
  * @see {@link https://docs.mux.com/guides/add-autogenerated-captions-and-use-transcripts}
47
56
  * @deprecated use `defaultAutogeneratedSubtitleLang` instead. Only a single autogenerated
@@ -50,7 +59,7 @@ export interface MuxInputConfig {
50
59
 
51
60
  /**
52
61
  * Auto-generate captions for this language by default. Users can still
53
- * Requires `"encoding_tier": "smart"`
62
+ * Requires `"video_quality": "plus"`
54
63
  *
55
64
  * @see {@link https://docs.mux.com/guides/add-autogenerated-captions-and-use-transcripts}
56
65
  */
@@ -123,9 +132,10 @@ export const SUPPORTED_MUX_LANGUAGES = [
123
132
  {label: 'Bulgarian', code: 'bg', state: 'Beta'},
124
133
  ] as const
125
134
 
126
- export const ENCODING_TIERS = [
127
- {label: 'Baseline', value: 'baseline'},
128
- {label: 'Smart', value: 'smart'},
135
+ export const VIDEO_QUALITY_LEVELS = [
136
+ {label: 'Basic', value: 'basic'},
137
+ {label: 'Plus', value: 'plus'},
138
+ {label: 'Premium', value: 'premium'},
129
139
  ] as const
130
140
 
131
141
  export const SUPPORTED_MUX_LANGUAGES_VALUES = SUPPORTED_MUX_LANGUAGES.map((l) => l.code)
@@ -168,7 +178,7 @@ export type UploadTextTrack = AutogeneratedTextTrack | CustomTextTrack
168
178
  export interface UploadConfig
169
179
  extends Pick<
170
180
  MuxInputConfig,
171
- 'encoding_tier' | 'max_resolution_tier' | 'mp4_support' | 'normalize_audio'
181
+ 'max_resolution_tier' | 'mp4_support' | 'normalize_audio' | 'video_quality'
172
182
  > {
173
183
  text_tracks: UploadTextTrack[]
174
184
  signed_policy: boolean
@@ -182,7 +192,7 @@ export interface UploadConfig
182
192
  export interface MuxNewAssetSettings
183
193
  extends Pick<
184
194
  MuxInputConfig,
185
- 'encoding_tier' | 'max_resolution_tier' | 'mp4_support' | 'normalize_audio'
195
+ 'max_resolution_tier' | 'mp4_support' | 'normalize_audio' | 'video_quality'
186
196
  > {
187
197
  /** An array of objects that each describe an input file to be used to create the asset.*/
188
198
  input?: {