@api.video/video-uploader
Version:
api.video video uploader
441 lines (403 loc) • 14.5 kB
text/typescript
export const MIN_CHUNK_SIZE = 1024 * 1024 * 5; // 5mb
export const DEFAULT_CHUNK_SIZE = 1024 * 1024 * 50; // 50mb
export const MAX_CHUNK_SIZE = 1024 * 1024 * 128; // 128mb
export const DEFAULT_RETRIES = 6;
export const DEFAULT_API_HOST = "ws.api.video";
export declare type VideoUploadResponse = {
readonly videoId: string;
readonly title?: string;
readonly description?: string;
readonly _public?: boolean;
readonly panoramic?: boolean;
readonly mp4Support?: boolean;
readonly publishedAt?: Date;
readonly createdAt?: Date;
readonly updatedAt?: Date;
readonly tags?: string[];
readonly metadata?: {
readonly key?: string;
readonly value?: string;
}[];
readonly source?: {
readonly type?: string;
readonly uri?: string;
};
readonly assets?: {
readonly iframe?: string;
readonly player?: string;
readonly hls?: string;
readonly thumbnail?: string;
readonly mp4?: string;
};
};
type RetryStrategy = (
retryCount: number,
error: VideoUploadError,
) => number | null;
interface Origin {
name: string;
version: string;
}
export interface CommonOptions {
apiHost?: string;
retries?: number;
videoName?: string;
retryStrategy?: RetryStrategy;
origin?: {
application?: Origin;
sdk?: Origin;
};
}
export interface WithUploadToken {
uploadToken: string;
videoId?: string;
}
export interface WithAccessToken {
accessToken: string;
refreshToken?: string;
videoId: string;
}
export interface WithApiKey {
apiKey: string;
videoId: string;
}
export type VideoUploadError = {
status?: number;
type?: string;
title?: string;
reason?: string;
raw: string;
};
type HXRRequestParams = {
parts?: {
currentPart: number;
totalParts: number | "*";
};
onProgress?: (e: ProgressEvent) => void;
body: Document | XMLHttpRequestBodyInit | null;
};
export interface CancelableOperation<T> {
cancel: () => void;
result: Promise<T>;
}
let PACKAGE_VERSION = "";
try {
// @ts-ignore
PACKAGE_VERSION = __PACKAGE_VERSION__ || "";
} catch (e) {
// ignore
}
export const DEFAULT_RETRY_STRATEGY = (maxRetries: number) => {
return (retryCount: number, error: VideoUploadError) => {
if (
(error.status && error.status >= 400 && error.status < 500) ||
retryCount >= maxRetries
) {
return null;
}
return Math.floor(200 + 2000 * retryCount * (retryCount + 1));
};
};
export abstract class AbstractUploader<T> {
protected uploadEndpoint: string;
protected videoId?: string;
protected retries: number;
protected headers: { [name: string]: string } = {};
protected onProgressCallbacks: ((e: T) => void)[] = [];
protected onPlayableCallbacks: ((e: VideoUploadResponse) => void)[] = [];
protected refreshToken?: string;
protected apiHost: string;
protected retryStrategy: RetryStrategy;
protected abortControllers: { [id: string]: AbortController } = {};
constructor(
options: CommonOptions & (WithAccessToken | WithUploadToken | WithApiKey),
) {
this.apiHost = options.apiHost || DEFAULT_API_HOST;
if (options.hasOwnProperty("uploadToken")) {
const optionsWithUploadToken = options as WithUploadToken;
if (optionsWithUploadToken.videoId) {
this.videoId = optionsWithUploadToken.videoId;
}
this.uploadEndpoint = `https://${this.apiHost}/upload?token=${optionsWithUploadToken.uploadToken}`;
} else if (options.hasOwnProperty("accessToken")) {
const optionsWithAccessToken = options as WithAccessToken;
if (!optionsWithAccessToken.videoId) {
throw new Error("'videoId' is missing");
}
this.refreshToken = optionsWithAccessToken.refreshToken;
this.uploadEndpoint = `https://${this.apiHost}/videos/${optionsWithAccessToken.videoId}/source`;
this.headers.Authorization = `Bearer ${optionsWithAccessToken.accessToken}`;
} else if (options.hasOwnProperty("apiKey")) {
const optionsWithApiKey = options as WithApiKey;
if (!optionsWithApiKey.videoId) {
throw new Error("'videoId' is missing");
}
this.uploadEndpoint = `https://${this.apiHost}/videos/${optionsWithApiKey.videoId}/source`;
this.headers.Authorization = `Basic ${btoa(
optionsWithApiKey.apiKey + ":",
)}`;
} else {
throw new Error(
`You must provide either an accessToken, an uploadToken or an API key`,
);
}
this.headers["AV-Origin-Client"] = "typescript-uploader:" + PACKAGE_VERSION;
this.retries = options.retries || DEFAULT_RETRIES;
this.retryStrategy =
options.retryStrategy || DEFAULT_RETRY_STRATEGY(this.retries);
if (options.origin) {
if (options.origin.application) {
AbstractUploader.validateOrigin(
"application",
options.origin.application,
);
this.headers[
"AV-Origin-App"
] = `${options.origin.application.name}:${options.origin.application.version}`;
}
if (options.origin.sdk) {
AbstractUploader.validateOrigin("sdk", options.origin.sdk);
this.headers[
"AV-Origin-Sdk"
] = `${options.origin.sdk.name}:${options.origin.sdk.version}`;
}
}
}
public onProgress(cb: (e: T) => void) {
this.onProgressCallbacks.push(cb);
}
public onPlayable(cb: (e: VideoUploadResponse) => void) {
this.onPlayableCallbacks.push(cb);
}
protected async waitForPlayable(video: VideoUploadResponse) {
const hls = video.assets?.hls;
while (true) {
await this.sleep(500);
const hlsRes = await fetch(hls!);
if (hlsRes.status === 202) {
continue;
}
if ((await hlsRes.text()).length === 0) {
continue;
}
break;
}
this.onPlayableCallbacks.forEach((cb) => cb(video));
}
protected parseErrorResponse(xhr: XMLHttpRequest): VideoUploadError {
try {
const parsedResponse = JSON.parse(xhr.response);
return {
status: xhr.status,
raw: xhr.response,
...parsedResponse,
};
} catch (e) {
// empty
}
return {
status: xhr.status,
raw: xhr.response,
reason: "UNKWOWN",
};
}
protected apiResponseToVideoUploadResponse(
response: any,
): VideoUploadResponse {
const res = {
...response,
_public: response.public,
publishedAt: response.publishedAt
? new Date(response.publishedAt)
: undefined,
createdAt: response.createdAt ? new Date(response.createdAt) : undefined,
updatedAt: response.updatedAt ? new Date(response.updatedAt) : undefined,
};
delete res.public;
return res;
}
protected sleep(duration: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), duration);
});
}
protected xhrWithRetrier(
params: HXRRequestParams,
): CancelableOperation<VideoUploadResponse> {
return this.withRetrier((abortController: AbortController) =>
this.createXhrPromise(params, abortController),
);
}
protected createFormData(
file: Blob,
fileName: string,
startByte?: number,
endByte?: number,
): FormData {
const chunk = startByte || endByte ? file.slice(startByte, endByte) : file;
const chunkForm = new FormData();
if (this.videoId) {
chunkForm.append("videoId", this.videoId);
}
chunkForm.append("file", chunk, fileName);
return chunkForm;
}
public doRefreshToken(): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest();
xhr.open("POST", `https://${this.apiHost}/auth/refresh`);
for (const headerName of Object.keys(this.headers)) {
if (headerName !== "Authorization")
xhr.setRequestHeader(headerName, this.headers[headerName]);
}
xhr.onreadystatechange = (_) => {
if (xhr.readyState === 4 && xhr.status >= 400) {
reject(this.parseErrorResponse(xhr));
}
};
xhr.onload = (_) => {
const response = JSON.parse(xhr.response);
if (response.refresh_token && response.access_token) {
this.headers.Authorization = `Bearer ${response.access_token}`;
this.refreshToken = response.refresh_token;
resolve();
return;
}
reject(this.parseErrorResponse(xhr));
};
xhr.send(
JSON.stringify({
refreshToken: this.refreshToken,
}),
);
});
}
private createXhrPromise(
params: HXRRequestParams,
abortController: AbortController,
): Promise<VideoUploadResponse> {
return new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest();
xhr.open("POST", `${this.uploadEndpoint}`, true);
abortController.signal.addEventListener("abort", () => {
xhr.abort();
reject({
status: undefined,
raw: undefined,
reason: "ABORTED",
});
});
if (params.parts) {
xhr.setRequestHeader(
"Content-Range",
`part ${params.parts.currentPart}/${params.parts.totalParts}`,
);
}
for (const headerName of Object.keys(this.headers)) {
xhr.setRequestHeader(headerName, this.headers[headerName]);
}
if (params.onProgress) {
xhr.upload.onprogress = (e) => params.onProgress!(e);
}
xhr.onreadystatechange = (_) => {
if (xhr.readyState === 4) {
// DONE
if (xhr.status === 401 && this.refreshToken) {
return this.doRefreshToken()
.then(() => this.createXhrPromise(params, abortController))
.then((res) => resolve(res))
.catch((e) => reject(e));
} else if (xhr.status >= 400) {
reject(this.parseErrorResponse(xhr));
return;
}
}
};
xhr.onerror = (e) => {
reject({
status: undefined,
raw: undefined,
reason: "NETWORK_ERROR",
});
};
xhr.ontimeout = (e) => {
reject({
status: undefined,
raw: undefined,
reason: "NETWORK_TIMEOUT",
});
};
xhr.onload = (_) => {
if (xhr.status < 400) {
resolve(
this.apiResponseToVideoUploadResponse(JSON.parse(xhr.response)),
);
}
};
xhr.send(params.body);
});
}
private withRetrier(
fn: (abortController: AbortController) => Promise<VideoUploadResponse>,
): CancelableOperation<VideoUploadResponse> {
// generate a unique random id for this upload
const id =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
const abortController = new AbortController();
this.abortControllers[id] = abortController;
const promise = new Promise<VideoUploadResponse>(
async (resolve, reject) => {
let retriesCount = 0;
while (true) {
try {
const res = await fn(abortController);
resolve(res);
return;
} catch (e: any) {
if (e.reason === "ABORTED") {
reject(e);
return;
}
const retryDelay = this.retryStrategy(retriesCount, e);
if (retryDelay === null) {
reject(e);
return;
}
console.log(
`video upload: ${e.reason || "ERROR"
}, will be retried in ${retryDelay} ms`,
);
await this.sleep(retryDelay);
retriesCount++;
}
}
},
);
return {
cancel: () => {
this.abortControllers[id].abort();
delete this.abortControllers[id];
},
result: promise,
};
}
private static validateOrigin(type: string, origin: Origin) {
if (!origin.name) {
throw new Error(`${type} name is required`);
}
if (!origin.version) {
throw new Error(`${type} version is required`);
}
if (!/^[\w-]{1,50}$/.test(origin.name)) {
throw new Error(
`Invalid ${type} name value. Allowed characters: A-Z, a-z, 0-9, '-', '_'. Max length: 50.`,
);
}
if (!/^\d{1,3}(\.\d{1,3}(\.\d{1,3})?)?$/.test(origin.version)) {
throw new Error(
`Invalid ${type} version value. The version should match the xxx[.yyy][.zzz] pattern.`,
);
}
}
}