about summary refs log tree commit diff
path: root/src/lib/media
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/media')
-rw-r--r--src/lib/media/video/compress.ts2
-rw-r--r--src/lib/media/video/upload.shared.ts61
-rw-r--r--src/lib/media/video/upload.ts79
-rw-r--r--src/lib/media/video/upload.web.ts95
-rw-r--r--src/lib/media/video/util.ts53
5 files changed, 289 insertions, 1 deletions
diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts
index dec9032a3..c2d1470c6 100644
--- a/src/lib/media/video/compress.ts
+++ b/src/lib/media/video/compress.ts
@@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
 import {ImagePickerAsset} from 'expo-image-picker'
 
 import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
-import {extToMime} from '#/state/queries/video/util'
 import {CompressedVideo} from './types'
+import {extToMime} from './util'
 
 const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
 
diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts
new file mode 100644
index 000000000..8c217eadc
--- /dev/null
+++ b/src/lib/media/video/upload.shared.ts
@@ -0,0 +1,61 @@
+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.`,
+        ),
+      )
+    }
+  }
+}
diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts
new file mode 100644
index 000000000..3330370b3
--- /dev/null
+++ b/src/lib/media/video/upload.ts
@@ -0,0 +1,79 @@
+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 './util'
+import {getServiceAuthToken, getVideoUploadLimits} from './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
+}
diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts
new file mode 100644
index 000000000..ec65f96c9
--- /dev/null
+++ b/src/lib/media/video/upload.web.ts
@@ -0,0 +1,95 @@
+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 './util'
+import {getServiceAuthToken, getVideoUploadLimits} from './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
+}
diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts
new file mode 100644
index 000000000..87b422c2c
--- /dev/null
+++ b/src/lib/media/video/util.ts
@@ -0,0 +1,53 @@
+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}`)
+  }
+}