Rename some files and variables (#5587)

* Move composer reducers together

* videoUploadState -> videoState

* Inline videoDispatch
This commit is contained in:
dan
2024-10-03 14:57:48 +09:00
committed by GitHub
parent 03704e2b48
commit 2aa365b5b6
10 changed files with 64 additions and 81 deletions
-53
View File
@@ -1,53 +0,0 @@
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
export const createVideoEndpointUrl = (
route: string,
params?: Record<string, string>,
) => {
const url = new URL(VIDEO_SERVICE)
url.pathname = route
if (params) {
for (const key in params) {
url.searchParams.set(key, params[key])
}
}
return url.href
}
export function createVideoAgent() {
return new AtpAgent({
service: VIDEO_SERVICE,
})
}
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
switch (mimeType) {
case 'video/mp4':
return 'mp4'
case 'video/webm':
return 'webm'
case 'video/mpeg':
return 'mpeg'
case 'video/quicktime':
return 'mov'
default:
throw new Error(`Unsupported mime type: ${mimeType}`)
}
}
export function extToMime(ext: string) {
switch (ext) {
case 'mp4':
return 'video/mp4'
case 'webm':
return 'video/webm'
case 'mpeg':
return 'video/mpeg'
case 'mov':
return 'video/quicktime'
default:
throw new Error(`Unsupported file extension: ${ext}`)
}
}
@@ -1,61 +0,0 @@
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {createVideoAgent} from './util'
export async function getServiceAuthToken({
agent,
aud,
lxm,
exp,
}: {
agent: BskyAgent
aud?: string
lxm: string
exp?: number
}) {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
}
export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
const token = await getServiceAuthToken({
agent,
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const videoAgent = createVideoAgent()
const {data: limits} = await videoAgent.app.bsky.video
.getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}
-79
View File
@@ -1,79 +0,0 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs, BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared'
export async function uploadVideo({
video,
agent,
did,
setProgress,
signal,
_,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${token}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
)
if (signal.aborted) {
throw new AbortError()
}
const res = await uploadTask.uploadAsync()
if (!res?.body) {
throw new Error('No response')
}
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
}
if (signal.aborted) {
throw new AbortError()
}
return responseBody
}
@@ -1,95 +0,0 @@
import {AppBskyVideoDefs} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared'
export async function uploadVideo({
video,
agent,
did,
setProgress,
signal,
_,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
let bytes = video.bytes
if (!bytes) {
if (signal.aborted) {
throw new AbortError()
}
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
if (signal.aborted) {
throw new AbortError()
}
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
setProgress(progress)
})
xhr.onloadend = () => {
if (signal.aborted) {
reject(new AbortError())
} else if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
}
if (signal.aborted) {
throw new AbortError()
}
return res
}
-406
View File
@@ -1,406 +0,0 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api'
import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {AbortError} from '#/lib/async/cancelable'
import {compressVideo} from '#/lib/media/video/compress'
import {
ServerError,
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {logger} from '#/logger'
import {createVideoAgent} from '#/state/queries/video/util'
import {uploadVideo} from '#/state/queries/video/video-upload'
export type VideoAction =
| {
type: 'compressing_to_uploading'
video: CompressedVideo
signal: AbortSignal
}
| {
type: 'uploading_to_processing'
jobId: string
signal: AbortSignal
}
| {type: 'to_error'; error: string; signal: AbortSignal}
| {
type: 'to_done'
blobRef: BlobRef
signal: AbortSignal
}
| {type: 'update_progress'; progress: number; signal: AbortSignal}
| {
type: 'update_dimensions'
width: number
height: number
signal: AbortSignal
}
| {
type: 'update_job_status'
jobStatus: AppBskyVideoDefs.JobStatus
signal: AbortSignal
}
const noopController = new AbortController()
noopController.abort()
export const NO_VIDEO = Object.freeze({
status: 'idle',
progress: 0,
abortController: noopController,
asset: undefined,
video: undefined,
jobId: undefined,
pendingPublish: undefined,
})
export type NoVideoState = typeof NO_VIDEO
type ErrorState = {
status: 'error'
progress: 100
abortController: AbortController
asset: ImagePickerAsset | null
video: CompressedVideo | null
jobId: string | null
error: string
pendingPublish?: undefined
}
type CompressingState = {
status: 'compressing'
progress: number
abortController: AbortController
asset: ImagePickerAsset
video?: undefined
jobId?: undefined
pendingPublish?: undefined
}
type UploadingState = {
status: 'uploading'
progress: number
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
}
type ProcessingState = {
status: 'processing'
progress: number
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
}
type DoneState = {
status: 'done'
progress: 100
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
}
export type VideoState =
| ErrorState
| CompressingState
| UploadingState
| ProcessingState
| DoneState
export function createVideoState(
asset: ImagePickerAsset,
abortController: AbortController,
): CompressingState {
return {
status: 'compressing',
progress: 0,
abortController,
asset,
}
}
export function videoReducer(
state: VideoState,
action: VideoAction,
): VideoState {
if (action.signal.aborted || action.signal !== state.abortController.signal) {
// This action is stale and the process that spawned it is no longer relevant.
return state
}
if (action.type === 'to_error') {
return {
status: 'error',
progress: 100,
abortController: state.abortController,
error: action.error,
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
}
} else if (action.type === 'update_progress') {
if (state.status === 'compressing' || state.status === 'uploading') {
return {
...state,
progress: action.progress,
}
}
} else if (action.type === 'update_dimensions') {
if (state.asset) {
return {
...state,
asset: {...state.asset, width: action.width, height: action.height},
}
}
} else if (action.type === 'compressing_to_uploading') {
if (state.status === 'compressing') {
return {
status: 'uploading',
progress: 0,
abortController: state.abortController,
asset: state.asset,
video: action.video,
}
}
return state
} else if (action.type === 'uploading_to_processing') {
if (state.status === 'uploading') {
return {
status: 'processing',
progress: 0,
abortController: state.abortController,
asset: state.asset,
video: state.video,
jobId: action.jobId,
jobStatus: null,
}
}
} else if (action.type === 'update_job_status') {
if (state.status === 'processing') {
return {
...state,
jobStatus: action.jobStatus,
progress:
action.jobStatus.progress !== undefined
? action.jobStatus.progress / 100
: state.progress,
}
}
} else if (action.type === 'to_done') {
if (state.status === 'processing') {
return {
status: 'done',
progress: 100,
abortController: state.abortController,
asset: state.asset,
video: state.video,
pendingPublish: {
blobRef: action.blobRef,
mutableProcessed: false,
},
}
}
}
console.error(
'Unexpected video action (' +
action.type +
') while in ' +
state.status +
' state',
)
return state
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
export async function processVideo(
asset: ImagePickerAsset,
dispatch: (action: VideoAction) => void,
agent: BskyAgent,
did: string,
signal: AbortSignal,
_: I18n['_'],
) {
let video: CompressedVideo | undefined
try {
video = await compressVideo(asset, {
onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
},
signal,
})
} catch (e) {
const message = getCompressErrorMessage(e, _)
if (message !== null) {
dispatch({
type: 'to_error',
error: message,
signal,
})
}
return
}
dispatch({
type: 'compressing_to_uploading',
video,
signal,
})
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
try {
uploadResponse = await uploadVideo({
video,
agent,
did,
signal,
_,
setProgress: p => {
dispatch({type: 'update_progress', progress: p, signal})
},
})
} catch (e) {
const message = getUploadErrorMessage(e, _)
if (message !== null) {
dispatch({
type: 'to_error',
error: message,
signal,
})
}
return
}
const jobId = uploadResponse.jobId
dispatch({
type: 'uploading_to_processing',
jobId,
signal,
})
let pollFailures = 0
while (true) {
if (signal.aborted) {
return // Exit async loop
}
const videoAgent = createVideoAgent()
let status: JobStatus | undefined
let blob: BlobRef | undefined
try {
const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
status = response.data.jobStatus
pollFailures = 0
if (status.state === 'JOB_STATE_COMPLETED') {
blob = status.blob
if (!blob) {
throw new Error('Job completed, but did not return a blob')
}
} else if (status.state === 'JOB_STATE_FAILED') {
throw new Error(status.error ?? 'Job failed to process')
}
} catch (e) {
if (!status) {
pollFailures++
if (pollFailures < 50) {
await new Promise(resolve => setTimeout(resolve, 5000))
continue // Continue async loop
}
}
logger.error('Error processing video', {safeMessage: e})
dispatch({
type: 'to_error',
error: _(msg`Video failed to process`),
signal,
})
return // Exit async loop
}
if (blob) {
dispatch({
type: 'to_done',
blobRef: blob,
signal,
})
} else {
dispatch({
type: 'update_job_status',
jobStatus: status,
signal,
})
}
if (
status.state !== 'JOB_STATE_COMPLETED' &&
status.state !== 'JOB_STATE_FAILED'
) {
await new Promise(resolve => setTimeout(resolve, 1500))
continue // Continue async loop
}
return // Exit async loop
}
}
function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
if (e instanceof AbortError) {
return null
}
if (e instanceof VideoTooLargeError) {
return _(msg`The selected video is larger than 50MB.`)
}
logger.error('Error compressing video', {safeMessage: e})
return _(msg`An error occurred while compressing the video.`)
}
function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
if (e instanceof AbortError) {
return null
}
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof ServerError || e instanceof UploadLimitError) {
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
return _(msg`You are not allowed to upload videos.`)
case 'Uploading is disabled at the moment':
return _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
case "Failed to get user's upload stats":
return _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
case 'User has exceeded daily upload bytes limit':
return _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
case 'User has exceeded daily upload videos limit':
return _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
case 'Account is not old enough to upload videos':
return _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
default:
return e.message
}
}
return _(msg`An error occurred while uploading the video.`)
}