lbry-desktop/web/setup/publish-v2.js
infinite-persistence 640237c630
tus: don't allow 'notify' to be sent again (#1778)
## Ticket
725

## Issue
Upload a video. When `notify` is sent at the end of the tus upload, refresh immediately. The GUI allowed the user to resume the upload, but the ID is no longer present in the server.

## Approach
Until the polling API for `notify` is available, we can only assume the best and let the user know how to handle it.
- Store the "notify was sent" state.
- Show a dialog explaining the situation.

Thought of trying to make `claim_list` calls behind the scenes to clear itself, but it doesn't handle the case of `notify` actually failing. The best is to just let the user handle it for now.

Note that for the case of `onerror` actually received, we still retry since a network error could be the culprit (`notify` wasn't sent).
2022-06-30 19:30:08 -04:00

157 lines
5.7 KiB
JavaScript

// @flow
import * as tus from 'tus-js-client';
import NoopUrlStorage from 'tus-js-client/lib/noopUrlStorage';
import analytics from '../../ui/analytics';
import { X_LBRY_AUTH_TOKEN } from '../../ui/constants/token';
import { doUpdateUploadAdd, doUpdateUploadProgress, doUpdateUploadRemove } from '../../ui/redux/actions/publish';
import { LBRY_WEB_PUBLISH_API_V2 } from 'config';
const RESUMABLE_ENDPOINT = LBRY_WEB_PUBLISH_API_V2;
const RESUMABLE_ENDPOINT_METHOD = 'publish';
const UPLOAD_CHUNK_SIZE_BYTE = 25 * 1024 * 1024;
const STATUS_CONFLICT = 409;
const STATUS_LOCKED = 423;
/**
* Checks whether a given status is in the range of the expected category.
*
* @param status
* @param category
* @returns {boolean}
*/
function inStatusCategory(status, category) {
return status >= category && status < category + 100;
}
function getTusErrorType(errMsg: string) {
if (errMsg.startsWith('tus: failed to upload chunk at offset')) {
// This is the only message that contains dynamic value prior to the first comma.
return 'tus: failed to upload chunk at offset';
} else {
return errMsg.startsWith('tus:') ? errMsg.substring(0, errMsg.indexOf(',')) : errMsg;
}
}
export function makeResumableUploadRequest(
token: string,
params: FileUploadSdkParams,
file: File | string,
isPreview?: boolean
) {
return new Promise<any>((resolve, reject) => {
if (!RESUMABLE_ENDPOINT) {
reject(new Error('Publish: endpoint undefined'));
}
if (params.remote_url) {
reject(new Error('Publish: v2 does not support remote_url'));
}
const { uploadUrl, guid, ...sdkParams } = params;
const jsonPayload = JSON.stringify({
jsonrpc: '2.0',
method: RESUMABLE_ENDPOINT_METHOD,
params: sdkParams,
id: new Date().getTime(),
});
const urlOptions = {};
if (params.uploadUrl) {
// Resuming from previous upload. TUS clears the resume fingerprint on any
// 4xx error, so we need to use the fixed URL mode instead.
urlOptions.uploadUrl = params.uploadUrl;
} else {
// New upload, so use `endpoint`.
urlOptions.endpoint = RESUMABLE_ENDPOINT;
}
const uploader = new tus.Upload(file, {
...urlOptions,
chunkSize: UPLOAD_CHUNK_SIZE_BYTE,
retryDelays: [8000, 10000, 15000, 20000, 30000],
parallelUploads: 1,
storeFingerprintForResuming: false,
urlStorage: new NoopUrlStorage(),
removeFingerprintOnSuccess: true,
headers: { [X_LBRY_AUTH_TOKEN]: token },
metadata: {
filename: file instanceof File ? file.name : file,
filetype: file instanceof File ? file.type : undefined,
},
onShouldRetry: (err, retryAttempt, options) => {
window.store.dispatch(doUpdateUploadProgress({ guid, status: 'retry' }));
const status = err.originalResponse ? err.originalResponse.getStatus() : 0;
return !inStatusCategory(status, 400) || status === STATUS_CONFLICT || status === STATUS_LOCKED;
},
onError: (err) => {
const status = err.originalResponse ? err.originalResponse.getStatus() : 0;
const errMsg = typeof err === 'string' ? err : err.message;
let customErr;
if (status === STATUS_LOCKED || errMsg === 'file currently locked') {
customErr = 'File is locked. Try resuming after waiting a few minutes';
}
window.store.dispatch(doUpdateUploadProgress({ guid, status: 'error' }));
analytics.sentryError(getTusErrorType(errMsg), { onError: err, tusUpload: uploader });
reject(
// $FlowFixMe - flow's constructor for Error is incorrect.
new Error(customErr || err, {
cause: {
// ...(uploader._fingerprint ? { fingerprint: uploader._fingerprint } : {}),
// ...(uploader._retryAttempt ? { retryAttempt: uploader._retryAttempt } : {}),
// ...(uploader._offsetBeforeRetry ? { offsetBeforeRetry: uploader._offsetBeforeRetry } : {}),
...(customErr ? { original: errMsg } : {}),
},
})
);
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
window.store.dispatch(doUpdateUploadProgress({ guid, progress: percentage }));
},
onSuccess: () => {
let retries = 1;
function makeNotifyRequest() {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${uploader.url}/notify`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Tus-Resumable', '1.0.0');
xhr.setRequestHeader(X_LBRY_AUTH_TOKEN, token);
xhr.responseType = 'json';
xhr.onloadstart = () => {
window.store.dispatch(doUpdateUploadProgress({ guid, status: 'notify' }));
};
xhr.onload = () => {
window.store.dispatch(doUpdateUploadRemove(guid));
resolve(xhr);
};
xhr.onerror = () => {
if (retries > 0 && xhr.status === 0) {
--retries;
analytics.error('notify: first attempt failed (status=0). Retrying after 10s...');
setTimeout(() => makeNotifyRequest(), 10000); // Auto-retry after 10s delay.
} else {
window.store.dispatch(doUpdateUploadProgress({ guid, status: 'error' }));
reject(new Error(`There was a problem in the processing. Please retry. (${xhr.status})`));
}
};
xhr.onabort = () => {
window.store.dispatch(doUpdateUploadRemove(guid));
};
xhr.send(jsonPayload);
}
setTimeout(() => makeNotifyRequest(), 15000);
},
});
window.store.dispatch(doUpdateUploadAdd(file, params, uploader));
uploader.start();
});
}