thread an explicit dispatch url through the video upload service auth
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,6 @@ import {app, com} from '#/lexicons'
|
|||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
import {createGIFDescription} from '../gif-alt-text'
|
import {createGIFDescription} from '../gif-alt-text'
|
||||||
import {computeCid} from './computeCid'
|
import {computeCid} from './computeCid'
|
||||||
import {fromLegacyBlobRef} from './legacy-blob'
|
|
||||||
import {uploadBlob} from './upload-blob'
|
import {uploadBlob} from './upload-blob'
|
||||||
|
|
||||||
export {uploadBlob}
|
export {uploadBlob}
|
||||||
@@ -410,10 +409,11 @@ async function resolveMedia(
|
|||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.video',
|
$type: 'app.bsky.embed.video',
|
||||||
/*
|
/*
|
||||||
* The video pipeline still reads its blob off the legacy agent, so
|
* The video blob is a plain lex blob from the video pipeline
|
||||||
* normalize it to the lex shape before it reaches the lex write.
|
* (getJobStatus, in composer state/video). Its structural shape matches
|
||||||
|
* the lexicon blob field and the CID hasher (see computeCid).
|
||||||
*/
|
*/
|
||||||
video: fromLegacyBlobRef(videoDraft.pendingPublish.blobRef),
|
video: videoDraft.pendingPublish.blobRef,
|
||||||
alt: videoDraft.altText || undefined,
|
alt: videoDraft.altText || undefined,
|
||||||
captions: captions.length === 0 ? undefined : captions,
|
captions: captions.length === 0 ? undefined : captions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
|||||||
@@ -14,18 +14,3 @@ import {type BlobRef as LexBlobRef} from '@atproto/lex'
|
|||||||
export function toLegacyBlobRef(blob: LexBlobRef): BlobRef {
|
export function toLegacyBlobRef(blob: LexBlobRef): BlobRef {
|
||||||
return BlobRef.fromJsonRef(blob as Parameters<typeof BlobRef.fromJsonRef>[0])
|
return BlobRef.fromJsonRef(blob as Parameters<typeof BlobRef.fromJsonRef>[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a legacy `BlobRef` class instance to the plain-JSON lex blob shape.
|
|
||||||
*
|
|
||||||
* Required for any blob that reaches a lex write: the lex serializer walks
|
|
||||||
* plain objects, so a class instance goes on the wire with its internal
|
|
||||||
* `original` field and no `$type`. `ipld()` yields exactly the lex shape, and
|
|
||||||
* hashes identically (see `computeCid.test.ts` case 2b).
|
|
||||||
*
|
|
||||||
* Only the video pipeline still needs this - it reads its blob off the legacy
|
|
||||||
* agent (`app.bsky.video.getJobStatus`). Drop it when the video client moves.
|
|
||||||
*/
|
|
||||||
export function fromLegacyBlobRef(blob: BlobRef): LexBlobRef {
|
|
||||||
return blob.ipld()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {type app} from '#/lexicons'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One part of a multipart upload. `partNumber` is 1-indexed to match the S3
|
* One part of a multipart upload. `partNumber` is 1-indexed to match the S3
|
||||||
* convention the backend uses.
|
* convention the backend uses.
|
||||||
@@ -57,13 +59,13 @@ export type UploadStatusResponse = {
|
|||||||
expiresAt: string
|
expiresAt: string
|
||||||
state: UploadState
|
state: UploadState
|
||||||
completedJobId?: string
|
completedJobId?: string
|
||||||
jobStatus?: import('@atproto/api').AppBskyVideoDefs.JobStatus
|
jobStatus?: app.bsky.video.defs.JobStatus
|
||||||
failureReason?: string
|
failureReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FinishUploadResponse = {
|
export type FinishUploadResponse = {
|
||||||
completedJobId: string
|
completedJobId: string
|
||||||
jobStatus: import('@atproto/api').AppBskyVideoDefs.JobStatus
|
jobStatus: app.bsky.video.defs.JobStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AbortUploadResponse = Pick<
|
export type AbortUploadResponse = Pick<
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
|
import {type Client} from '@atproto/lex'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {AbortError} from '#/lib/async/cancelable'
|
import {AbortError} from '#/lib/async/cancelable'
|
||||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {shouldRetryError} from '#/lib/strings/errors'
|
import {shouldRetryError} from '#/lib/strings/errors'
|
||||||
|
import {type app} from '#/lexicons'
|
||||||
import {getServiceAuthToken} from '../upload.shared'
|
import {getServiceAuthToken} from '../upload.shared'
|
||||||
import {mimeToExt} from '../util'
|
import {mimeToExt} from '../util'
|
||||||
import {
|
import {
|
||||||
@@ -29,19 +30,22 @@ export class MultipartFallbackError extends Error {}
|
|||||||
|
|
||||||
export async function uploadVideoMultipart({
|
export async function uploadVideoMultipart({
|
||||||
video,
|
video,
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
setProgress,
|
setProgress,
|
||||||
signal,
|
signal,
|
||||||
onStarted,
|
onStarted,
|
||||||
}: {
|
}: {
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
agent: AtpAgent
|
client: Client
|
||||||
|
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
|
||||||
|
dispatchUrl: string | URL
|
||||||
setProgress: (progress: number) => void
|
setProgress: (progress: number) => void
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
onStarted?: () => void
|
onStarted?: () => void
|
||||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
}): Promise<app.bsky.video.defs.JobStatus> {
|
||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
const tokenProvider = createTokenProvider(agent, signal)
|
const tokenProvider = createTokenProvider(client, dispatchUrl, signal)
|
||||||
const token = await tokenProvider.get()
|
const token = await tokenProvider.get()
|
||||||
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
|
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
|
||||||
let session
|
let session
|
||||||
@@ -134,7 +138,7 @@ async function finishAndRecover({
|
|||||||
getToken: (forceRefresh?: boolean) => Promise<string>
|
getToken: (forceRefresh?: boolean) => Promise<string>
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
|
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
|
||||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
}): Promise<app.bsky.video.defs.JobStatus> {
|
||||||
let createdFailures = 0
|
let createdFailures = 0
|
||||||
let forceTokenRefresh = true
|
let forceTokenRefresh = true
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -224,7 +228,7 @@ async function abortThenFallbackOrResolve(
|
|||||||
jobId: string,
|
jobId: string,
|
||||||
token: string,
|
token: string,
|
||||||
cause: unknown,
|
cause: unknown,
|
||||||
): Promise<AppBskyVideoDefs.JobStatus> {
|
): Promise<app.bsky.video.defs.JobStatus> {
|
||||||
const result = await abortUploadWithRetry(jobId, token)
|
const result = await abortUploadWithRetry(jobId, token)
|
||||||
if (result.state === 'aborted') {
|
if (result.state === 'aborted') {
|
||||||
throw new MultipartFallbackError(
|
throw new MultipartFallbackError(
|
||||||
@@ -264,7 +268,11 @@ async function abortUploadWithRetry(jobId: string, token: string) {
|
|||||||
throw lastError
|
throw lastError
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
|
function createTokenProvider(
|
||||||
|
client: Client,
|
||||||
|
dispatchUrl: string | URL,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) {
|
||||||
let token: string | undefined
|
let token: string | undefined
|
||||||
let expiresAt = 0
|
let expiresAt = 0
|
||||||
let refresh: Promise<string> | undefined
|
let refresh: Promise<string> | undefined
|
||||||
@@ -273,7 +281,7 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
|
|||||||
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
|
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
|
||||||
if (!refresh) {
|
if (!refresh) {
|
||||||
const exp = Math.floor(Date.now() / 1000) + 60 * 30
|
const exp = Math.floor(Date.now() / 1000) + 60 * 30
|
||||||
refresh = getServiceAuthTokenWithRetry(agent, exp, signal)
|
refresh = getServiceAuthTokenWithRetry(client, dispatchUrl, exp, signal)
|
||||||
.then(nextToken => {
|
.then(nextToken => {
|
||||||
token = nextToken
|
token = nextToken
|
||||||
expiresAt = exp * 1000
|
expiresAt = exp * 1000
|
||||||
@@ -290,7 +298,8 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getServiceAuthTokenWithRetry(
|
async function getServiceAuthTokenWithRetry(
|
||||||
agent: AtpAgent,
|
client: Client,
|
||||||
|
dispatchUrl: string | URL,
|
||||||
exp: number,
|
exp: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
) {
|
) {
|
||||||
@@ -299,7 +308,8 @@ async function getServiceAuthTokenWithRetry(
|
|||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
try {
|
try {
|
||||||
return await getServiceAuthToken({
|
return await getServiceAuthToken({
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
exp,
|
exp,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,44 +1,62 @@
|
|||||||
import {type AtpAgent} from '@atproto/api'
|
import {type Client} from '@atproto/lex'
|
||||||
|
import {type DidString, type NsidString} from '@atproto/syntax'
|
||||||
import {type I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
|
|
||||||
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
||||||
import {UploadLimitError} from '#/lib/media/video/errors'
|
import {UploadLimitError} from '#/lib/media/video/errors'
|
||||||
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
||||||
import {createVideoAgent} from './util'
|
import {app, com} from '#/lexicons'
|
||||||
|
import {createVideoServiceClient} from './util'
|
||||||
|
|
||||||
export async function getServiceAuthToken({
|
export async function getServiceAuthToken({
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
aud,
|
aud,
|
||||||
lxm,
|
lxm,
|
||||||
exp,
|
exp,
|
||||||
}: {
|
}: {
|
||||||
agent: AtpAgent
|
client: Client
|
||||||
|
/**
|
||||||
|
* The account's dispatch URL (the old `agent.dispatchUrl`: its PDS, falling
|
||||||
|
* back to the account service). Only needed when `aud` is omitted, so the
|
||||||
|
* default audience can be derived from the PDS host. A lex {@link Client} does
|
||||||
|
* not expose this - it resolves the PDS per request internally - so the caller,
|
||||||
|
* which holds the session, passes it in.
|
||||||
|
*/
|
||||||
|
dispatchUrl?: string | URL
|
||||||
aud?: string
|
aud?: string
|
||||||
lxm: string
|
lxm: NsidString
|
||||||
exp?: number
|
exp?: number
|
||||||
}) {
|
}) {
|
||||||
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
let resolvedAud = aud
|
||||||
if (!pdsAud) {
|
if (!resolvedAud) {
|
||||||
throw new Error('Agent does not have a PDS URL')
|
if (!dispatchUrl) {
|
||||||
|
throw new Error('Missing service auth audience: no aud or dispatchUrl')
|
||||||
|
}
|
||||||
|
const pdsAud = getServiceAuthAudFromUrl(dispatchUrl)
|
||||||
|
if (!pdsAud) {
|
||||||
|
throw new Error('Agent does not have a PDS URL')
|
||||||
|
}
|
||||||
|
resolvedAud = pdsAud
|
||||||
}
|
}
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
const {token} = await client.call(com.atproto.server.getServiceAuth, {
|
||||||
aud: aud ?? pdsAud,
|
aud: resolvedAud as DidString,
|
||||||
lxm,
|
lxm,
|
||||||
exp,
|
exp,
|
||||||
})
|
})
|
||||||
return serviceAuth.token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) {
|
export async function getVideoUploadLimits(client: Client, i18n: I18n) {
|
||||||
const token = await getServiceAuthToken({
|
const token = await getServiceAuthToken({
|
||||||
agent,
|
client,
|
||||||
lxm: 'app.bsky.video.getUploadLimits',
|
lxm: 'app.bsky.video.getUploadLimits',
|
||||||
aud: VIDEO_SERVICE_DID,
|
aud: VIDEO_SERVICE_DID,
|
||||||
})
|
})
|
||||||
const videoAgent = createVideoAgent()
|
const videoClient = createVideoServiceClient(token)
|
||||||
const {data: limits} = await videoAgent.app.bsky.video
|
const limits = await videoClient
|
||||||
.getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
|
.call(app.bsky.video.getUploadLimits)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
throw new UploadLimitError(err.message)
|
throw new UploadLimitError(err.message)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
|
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
|
||||||
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
|
import {type Client} from '@atproto/lex'
|
||||||
import {type I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
@@ -11,13 +11,15 @@ import {
|
|||||||
type VideoUploadTransport,
|
type VideoUploadTransport,
|
||||||
} from '#/lib/media/video/types'
|
} from '#/lib/media/video/types'
|
||||||
import {Features, features} from '#/analytics/features'
|
import {Features, features} from '#/analytics/features'
|
||||||
|
import {type app} from '#/lexicons'
|
||||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from './util'
|
import {createVideoEndpointUrl, mimeToExt} from './util'
|
||||||
|
|
||||||
export async function uploadVideo({
|
export async function uploadVideo({
|
||||||
video,
|
video,
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
did,
|
did,
|
||||||
setProgress,
|
setProgress,
|
||||||
signal,
|
signal,
|
||||||
@@ -25,7 +27,9 @@ export async function uploadVideo({
|
|||||||
onTransport,
|
onTransport,
|
||||||
}: {
|
}: {
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
agent: AtpAgent
|
client: Client
|
||||||
|
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
|
||||||
|
dispatchUrl: string | URL
|
||||||
did: string
|
did: string
|
||||||
setProgress: (progress: number) => void
|
setProgress: (progress: number) => void
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
@@ -35,13 +39,14 @@ export async function uploadVideo({
|
|||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw new AbortError()
|
throw new AbortError()
|
||||||
}
|
}
|
||||||
await getVideoUploadLimits(agent, i18n)
|
await getVideoUploadLimits(client, i18n)
|
||||||
|
|
||||||
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
||||||
try {
|
try {
|
||||||
return await uploadVideoMultipart({
|
return await uploadVideoMultipart({
|
||||||
video,
|
video,
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
setProgress,
|
setProgress,
|
||||||
signal,
|
signal,
|
||||||
onStarted: () => onTransport?.('multipart'),
|
onStarted: () => onTransport?.('multipart'),
|
||||||
@@ -64,7 +69,8 @@ export async function uploadVideo({
|
|||||||
throw new AbortError()
|
throw new AbortError()
|
||||||
}
|
}
|
||||||
const token = await getServiceAuthToken({
|
const token = await getServiceAuthToken({
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
})
|
})
|
||||||
@@ -91,7 +97,7 @@ export async function uploadVideo({
|
|||||||
throw new Error('No response')
|
throw new Error('No response')
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
const responseBody = JSON.parse(res.body) as app.bsky.video.defs.JobStatus
|
||||||
|
|
||||||
if (!responseBody.jobId) {
|
if (!responseBody.jobId) {
|
||||||
throw new ServerError(
|
throw new ServerError(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
|
import {type Client} from '@atproto/lex'
|
||||||
import {type I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
@@ -10,13 +10,15 @@ import {
|
|||||||
type VideoUploadTransport,
|
type VideoUploadTransport,
|
||||||
} from '#/lib/media/video/types'
|
} from '#/lib/media/video/types'
|
||||||
import {Features, features} from '#/analytics/features'
|
import {Features, features} from '#/analytics/features'
|
||||||
|
import {type app} from '#/lexicons'
|
||||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from './util'
|
import {createVideoEndpointUrl, mimeToExt} from './util'
|
||||||
|
|
||||||
export async function uploadVideo({
|
export async function uploadVideo({
|
||||||
video,
|
video,
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
did,
|
did,
|
||||||
setProgress,
|
setProgress,
|
||||||
signal,
|
signal,
|
||||||
@@ -24,7 +26,9 @@ export async function uploadVideo({
|
|||||||
onTransport,
|
onTransport,
|
||||||
}: {
|
}: {
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
agent: AtpAgent
|
client: Client
|
||||||
|
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
|
||||||
|
dispatchUrl: string | URL
|
||||||
did: string
|
did: string
|
||||||
setProgress: (progress: number) => void
|
setProgress: (progress: number) => void
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
@@ -34,13 +38,14 @@ export async function uploadVideo({
|
|||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw new AbortError()
|
throw new AbortError()
|
||||||
}
|
}
|
||||||
await getVideoUploadLimits(agent, i18n)
|
await getVideoUploadLimits(client, i18n)
|
||||||
|
|
||||||
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
||||||
try {
|
try {
|
||||||
return await uploadVideoMultipart({
|
return await uploadVideoMultipart({
|
||||||
video,
|
video,
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
setProgress,
|
setProgress,
|
||||||
signal,
|
signal,
|
||||||
onStarted: () => onTransport?.('multipart'),
|
onStarted: () => onTransport?.('multipart'),
|
||||||
@@ -71,7 +76,8 @@ export async function uploadVideo({
|
|||||||
throw new AbortError()
|
throw new AbortError()
|
||||||
}
|
}
|
||||||
const token = await getServiceAuthToken({
|
const token = await getServiceAuthToken({
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
})
|
})
|
||||||
@@ -80,7 +86,7 @@ export async function uploadVideo({
|
|||||||
throw new AbortError()
|
throw new AbortError()
|
||||||
}
|
}
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
const res = await new Promise<app.bsky.video.defs.JobStatus>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
xhr.upload.addEventListener('progress', e => {
|
xhr.upload.addEventListener('progress', e => {
|
||||||
const progress = e.loaded / e.total
|
const progress = e.loaded / e.total
|
||||||
@@ -92,7 +98,7 @@ export async function uploadVideo({
|
|||||||
} else if (xhr.readyState === 4) {
|
} else if (xhr.readyState === 4) {
|
||||||
const uploadRes = JSON.parse(
|
const uploadRes = JSON.parse(
|
||||||
xhr.responseText,
|
xhr.responseText,
|
||||||
) as AppBskyVideoDefs.JobStatus
|
) as app.bsky.video.defs.JobStatus
|
||||||
resolve(uploadRes)
|
resolve(uploadRes)
|
||||||
} else {
|
} else {
|
||||||
reject(new ServerError(i18n._(msg`Failed to upload video`)))
|
reject(new ServerError(i18n._(msg`Failed to upload video`)))
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {AtpAgent} from '@atproto/api'
|
|
||||||
|
|
||||||
import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
||||||
|
import {createLexClient} from '#/lib/lexClient'
|
||||||
|
|
||||||
export const createVideoEndpointUrl = (
|
export const createVideoEndpointUrl = (
|
||||||
route: string,
|
route: string,
|
||||||
@@ -16,12 +15,29 @@ export const createVideoEndpointUrl = (
|
|||||||
return url.href
|
return url.href
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVideoAgent() {
|
/**
|
||||||
return new AtpAgent({
|
* A non-refreshing single-use lex {@link Client} scoped to the video service and
|
||||||
|
* authenticated by a per-call service-auth token. It has no session, so nothing
|
||||||
|
* can refresh it: requests go straight to the video service with the token as a
|
||||||
|
* static `authorization` header, which a raw client - unlike a session-backed
|
||||||
|
* one - is allowed to preset. Mirrors the scoped client in
|
||||||
|
* `#/ageAssurance/useBeginAgeAssurance`.
|
||||||
|
*/
|
||||||
|
export function createVideoServiceClient(token: string) {
|
||||||
|
return createLexClient({
|
||||||
service: VIDEO_SERVICE,
|
service: VIDEO_SERVICE,
|
||||||
|
headers: {authorization: `Bearer ${token}`},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unauthenticated lex {@link Client} scoped to the video service, for public
|
||||||
|
* reads like `getJobStatus` polling.
|
||||||
|
*/
|
||||||
|
export function createTokenlessVideoServiceClient() {
|
||||||
|
return createLexClient({service: VIDEO_SERVICE})
|
||||||
|
}
|
||||||
|
|
||||||
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
||||||
switch (mimeType) {
|
switch (mimeType) {
|
||||||
case 'video/mp4':
|
case 'video/mp4':
|
||||||
|
|||||||
@@ -285,6 +285,12 @@ export const ComposePost = ({
|
|||||||
const pdsClient = usePdsClient()
|
const pdsClient = usePdsClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const currentDid = currentAccount!.did
|
const currentDid = currentAccount!.did
|
||||||
|
/*
|
||||||
|
* The host the video service-auth token is minted for. This is the same value
|
||||||
|
* that seeds the session's PDS routing, so the audience always matches the host
|
||||||
|
* the upload actually reaches; a mismatch would 401 the upload.
|
||||||
|
*/
|
||||||
|
const currentDispatchUrl = currentAccount!.pdsUrl ?? currentAccount!.service
|
||||||
const {closeComposer} = useComposerControls()
|
const {closeComposer} = useComposerControls()
|
||||||
const {t: l, i18n} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const requireAltTextEnabled = useRequireAltTextEnabled()
|
const requireAltTextEnabled = useRequireAltTextEnabled()
|
||||||
@@ -479,7 +485,8 @@ export const ComposePost = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
agent,
|
pdsClient,
|
||||||
|
currentDispatchUrl,
|
||||||
currentDid,
|
currentDid,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
i18n,
|
i18n,
|
||||||
@@ -489,7 +496,8 @@ export const ComposePost = ({
|
|||||||
[
|
[
|
||||||
l,
|
l,
|
||||||
i18n,
|
i18n,
|
||||||
agent,
|
pdsClient,
|
||||||
|
currentDispatchUrl,
|
||||||
currentDid,
|
currentDid,
|
||||||
composerDispatch,
|
composerDispatch,
|
||||||
ax.metric,
|
ax.metric,
|
||||||
@@ -661,7 +669,8 @@ export const ComposePost = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
agent,
|
pdsClient,
|
||||||
|
currentDispatchUrl,
|
||||||
currentDid,
|
currentDid,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
i18n,
|
i18n,
|
||||||
@@ -677,7 +686,8 @@ export const ComposePost = ({
|
|||||||
[
|
[
|
||||||
l,
|
l,
|
||||||
i18n,
|
i18n,
|
||||||
agent,
|
pdsClient,
|
||||||
|
currentDispatchUrl,
|
||||||
currentDid,
|
currentDid,
|
||||||
composerDispatch,
|
composerDispatch,
|
||||||
ax.metric,
|
ax.metric,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api'
|
import {type BlobRef, type Client} from '@atproto/lex'
|
||||||
import {type I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
|
|
||||||
@@ -14,9 +14,10 @@ import {
|
|||||||
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {uploadVideo} from '#/lib/media/video/upload'
|
import {uploadVideo} from '#/lib/media/video/upload'
|
||||||
import {createVideoAgent} from '#/lib/media/video/util'
|
import {createTokenlessVideoServiceClient} from '#/lib/media/video/util'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {app} from '#/lexicons'
|
||||||
import {
|
import {
|
||||||
advanceVideoProgress,
|
advanceVideoProgress,
|
||||||
didSkipVideoCompression,
|
didSkipVideoCompression,
|
||||||
@@ -56,7 +57,7 @@ export type VideoAction =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'update_job_status'
|
type: 'update_job_status'
|
||||||
jobStatus: AppBskyVideoDefs.JobStatus
|
jobStatus: app.bsky.video.defs.JobStatus
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ type ProcessingState = {
|
|||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
jobId: string
|
jobId: string
|
||||||
jobStatus: AppBskyVideoDefs.JobStatus | null
|
jobStatus: app.bsky.video.defs.JobStatus | null
|
||||||
pendingPublish?: undefined
|
pendingPublish?: undefined
|
||||||
telemetry: VideoTelemetry
|
telemetry: VideoTelemetry
|
||||||
altText: string
|
altText: string
|
||||||
@@ -295,7 +296,8 @@ function trunc2dp(num: number) {
|
|||||||
export async function processVideo(
|
export async function processVideo(
|
||||||
asset: ImagePickerAsset,
|
asset: ImagePickerAsset,
|
||||||
dispatch: (action: VideoAction) => void,
|
dispatch: (action: VideoAction) => void,
|
||||||
agent: AtpAgent,
|
client: Client,
|
||||||
|
dispatchUrl: string | URL,
|
||||||
did: string,
|
did: string,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
@@ -339,12 +341,13 @@ export async function processVideo(
|
|||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
let uploadResponse: app.bsky.video.defs.JobStatus | undefined
|
||||||
try {
|
try {
|
||||||
telemetry.uploadStarted(video.size)
|
telemetry.uploadStarted(video.size)
|
||||||
uploadResponse = await uploadVideo({
|
uploadResponse = await uploadVideo({
|
||||||
video,
|
video,
|
||||||
agent,
|
client,
|
||||||
|
dispatchUrl,
|
||||||
did,
|
did,
|
||||||
signal,
|
signal,
|
||||||
i18n,
|
i18n,
|
||||||
@@ -381,12 +384,14 @@ export async function processVideo(
|
|||||||
return // Exit async loop
|
return // Exit async loop
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoAgent = createVideoAgent()
|
const videoClient = createTokenlessVideoServiceClient()
|
||||||
let status: AppBskyVideoDefs.JobStatus | undefined
|
let status: app.bsky.video.defs.JobStatus | undefined
|
||||||
let blob: BlobRef | undefined
|
let blob: BlobRef | undefined
|
||||||
try {
|
try {
|
||||||
const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
|
const response = await videoClient.call(app.bsky.video.getJobStatus, {
|
||||||
status = response.data.jobStatus
|
jobId,
|
||||||
|
})
|
||||||
|
status = response.jobStatus
|
||||||
pollFailures = 0
|
pollFailures = 0
|
||||||
|
|
||||||
if (status.state === 'JOB_STATE_COMPLETED') {
|
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||||
|
|||||||
Reference in New Issue
Block a user