From bef7ff4a2d9eef4cdd7b2e4461c2e8457ac5b1b4 Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Tue, 12 Oct 2021 17:21:19 +0800 Subject: [PATCH] Support resume-able upload via tus ## Issue 38 Handle resumable file upload ## Notes Since we can't serialize a File object, we'll need to the user to re-select the file to resume. --- extras/lbryinc/index.js | 2 +- extras/lbryinc/redux/actions/web.js | 28 +++- extras/lbryinc/redux/reducers/web.js | 97 +++++++----- flow-typed/File.js | 9 ++ package.json | 1 + static/app-strings.json | 10 ++ ui/component/webUploadList/index.js | 17 ++- .../internal/web-upload-item.jsx | 142 +++++++++++++++--- ui/component/webUploadList/view.jsx | 25 +-- ui/constants/action_types.js | 2 + ui/scss/component/_claim-list.scss | 19 +++ ui/util/file.js | 8 + web/setup/publish.js | 103 +++++++++---- yarn.lock | 96 +++++++++++- 14 files changed, 448 insertions(+), 111 deletions(-) create mode 100644 ui/util/file.js diff --git a/extras/lbryinc/index.js b/extras/lbryinc/index.js index bb64484d7..b62b3a998 100644 --- a/extras/lbryinc/index.js +++ b/extras/lbryinc/index.js @@ -27,7 +27,7 @@ export { doResetSync, doSyncEncryptAndDecrypt, } from 'redux/actions/sync'; -export { doUpdateUploadProgress } from './redux/actions/web'; +export { doUpdateUploadAdd, doUpdateUploadProgress, doUpdateUploadRemove } from './redux/actions/web'; // reducers export { authReducer } from './redux/reducers/auth'; diff --git a/extras/lbryinc/redux/actions/web.js b/extras/lbryinc/redux/actions/web.js index 1bcce69a5..562d6e1a0 100644 --- a/extras/lbryinc/redux/actions/web.js +++ b/extras/lbryinc/redux/actions/web.js @@ -1,12 +1,30 @@ // @flow import * as ACTIONS from 'constants/action_types'; -export const doUpdateUploadProgress = ( - progress: string, +export function doUpdateUploadAdd(file: File, params: { [key: string]: any }, tusUploader: any) { + return (dispatch: Dispatch, getState: GetState) => { + dispatch({ + type: ACTIONS.UPDATE_UPLOAD_ADD, + data: { file, params, tusUploader }, + }); + }; +} + +export const doUpdateUploadProgress = (props: { params: { [key: string]: any }, - xhr: any -) => (dispatch: Dispatch) => + progress?: string, + status?: string, +}) => (dispatch: Dispatch) => dispatch({ type: ACTIONS.UPDATE_UPLOAD_PROGRESS, - data: { progress, params, xhr }, + data: props, }); + +export function doUpdateUploadRemove(params: { [key: string]: any }) { + return (dispatch: Dispatch, getState: GetState) => { + dispatch({ + type: ACTIONS.UPDATE_UPLOAD_REMOVE, + data: { params }, + }); + }; +} diff --git a/extras/lbryinc/redux/reducers/web.js b/extras/lbryinc/redux/reducers/web.js index 7cb9f674d..60ed9ccc5 100644 --- a/extras/lbryinc/redux/reducers/web.js +++ b/extras/lbryinc/redux/reducers/web.js @@ -1,34 +1,12 @@ // @flow import * as ACTIONS from 'constants/action_types'; +import { serializeFileObj } from 'util/file'; -/* -test mock: - currentUploads: { - 'test#upload': { - progress: 50, - params: { - name: 'steve', - thumbnail_url: 'https://dev2.spee.ch/4/KMNtoSZ009fawGz59VG8PrID.jpeg', - }, - }, - }, - */ - -export type Params = { - channel?: string, - name: string, - thumbnail_url: ?string, - title: ?string, -}; - -export type UploadItem = { - progess: string, - params: Params, - xhr?: any, -}; +const CURRENT_UPLOADS = 'current_uploads'; +const getKeyFromParam = (params) => `${params.name}#${params.channel || 'anonymous'}`; export type TvState = { - currentUploads: { [key: string]: UploadItem }, + currentUploads: { [key: string]: FileUploadItem }, }; const reducers = {}; @@ -37,21 +15,62 @@ const defaultState: TvState = { currentUploads: {}, }; -reducers[ACTIONS.UPDATE_UPLOAD_PROGRESS] = (state: TvState, action) => { - const { progress, params, xhr } = action.data; - const key = params.channel ? `${params.name}#${params.channel}` : `${params.name}#anonymous`; - let currentUploads; - if (!progress) { - currentUploads = Object.assign({}, state.currentUploads); - Object.keys(currentUploads).forEach(k => { - if (k === key) { - delete currentUploads[key]; - } +try { + const uploads = JSON.parse(window.localStorage.getItem(CURRENT_UPLOADS)); + if (uploads) { + defaultState.currentUploads = uploads; + Object.keys(defaultState.currentUploads).forEach((key) => { + delete defaultState.currentUploads[key].tusUploader; }); - } else { - currentUploads = Object.assign({}, state.currentUploads); - currentUploads[key] = { progress, params, xhr }; } +} catch (e) { + console.log(e); +} + +reducers[ACTIONS.UPDATE_UPLOAD_ADD] = (state: TvState, action) => { + const { file, params, tusUploader } = action.data; + const key = getKeyFromParam(params); + const currentUploads = Object.assign({}, state.currentUploads); + + currentUploads[key] = { + file, + fileFingerprint: serializeFileObj(file), // TODO: get hash instead? + progress: '0', + params, + tusUploader, + }; + + window.localStorage.setItem(CURRENT_UPLOADS, JSON.stringify(currentUploads)); + return { ...state, currentUploads }; +}; + +reducers[ACTIONS.UPDATE_UPLOAD_PROGRESS] = (state: TvState, action) => { + const { params, progress, status } = action.data; + const key = getKeyFromParam(params); + const currentUploads = Object.assign({}, state.currentUploads); + + if (progress) { + currentUploads[key].progress = progress; + delete currentUploads[key].status; + } else if (status) { + currentUploads[key].status = status; + if (status === 'error') { + delete currentUploads[key].tusUploader; + } + } + + window.localStorage.setItem(CURRENT_UPLOADS, JSON.stringify(currentUploads)); + return { ...state, currentUploads }; +}; + +reducers[ACTIONS.UPDATE_UPLOAD_REMOVE] = (state: TvState, action) => { + const { params } = action.data; + const key = getKeyFromParam(params); + const currentUploads = Object.assign({}, state.currentUploads); + + delete currentUploads[key]; + + window.localStorage.setItem(CURRENT_UPLOADS, JSON.stringify(currentUploads)); return { ...state, currentUploads }; }; diff --git a/flow-typed/File.js b/flow-typed/File.js index 44dc4f398..581319441 100644 --- a/flow-typed/File.js +++ b/flow-typed/File.js @@ -76,3 +76,12 @@ declare type DeletePurchasedUri = { uri: string, }, }; + +declare type FileUploadItem = { + params: UpdatePublishFormData, + file: File, + fileFingerprint: string, + progress: string, + status?: string, + tusUploader?: any, +}; diff --git a/package.json b/package.json index 24d772d98..8b84fc9f7 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "rss": "^1.2.2", "source-map-explorer": "^2.5.2", "tempy": "^0.6.0", + "tus-js-client": "^2.3.0", "videojs-contrib-ads": "^6.9.0", "videojs-ima": "^1.11.0", "videojs-ima-player": "^0.5.6", diff --git a/static/app-strings.json b/static/app-strings.json index bf5911498..b195710c8 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1291,6 +1291,16 @@ "Select a file to upload": "Select a file to upload", "Select file to upload": "Select file to upload", "Url copied.": "Url copied.", + "Failed to initiate upload (%err%)": "Failed to initiate upload (%err%)", + "Invalid file": "Invalid file", + "It appears to be a different or modified file.": "It appears to be a different or modified file.", + "Please select the same file from the initial upload.": "Please select the same file from the initial upload.", + "Cancel upload": "Cancel upload", + "Cancel and remove the selected upload?": "Cancel and remove the selected upload?", + "Select the file to resume upload...": "Select the file to resume upload...", + "Stopped.": "Stopped.", + "Resume": "Resume", + "Retrying...": "Retrying...", "Uploading...": "Uploading...", "Creating...": "Creating...", "Use a URL": "Use a URL", diff --git a/ui/component/webUploadList/index.js b/ui/component/webUploadList/index.js index 73023158e..35a754c09 100644 --- a/ui/component/webUploadList/index.js +++ b/ui/component/webUploadList/index.js @@ -1,13 +1,18 @@ import { connect } from 'react-redux'; -import { selectCurrentUploads, selectUploadCount } from 'lbryinc'; +import { selectCurrentUploads, selectUploadCount, doUpdateUploadRemove } from 'lbryinc'; +import { doOpenModal } from 'redux/actions/app'; +import { doPublishResume } from 'redux/actions/publish'; import WebUploadList from './view'; -const select = state => ({ +const select = (state) => ({ currentUploads: selectCurrentUploads(state), uploadCount: selectUploadCount(state), }); -export default connect( - select, - null -)(WebUploadList); +const perform = { + doPublishResume, + doUpdateUploadRemove, + doOpenModal, +}; + +export default connect(select, perform)(WebUploadList); diff --git a/ui/component/webUploadList/internal/web-upload-item.jsx b/ui/component/webUploadList/internal/web-upload-item.jsx index 39177746d..04548e924 100644 --- a/ui/component/webUploadList/internal/web-upload-item.jsx +++ b/ui/component/webUploadList/internal/web-upload-item.jsx @@ -1,40 +1,138 @@ // @flow -import React from 'react'; +import React, { useState } from 'react'; +import FileSelector from 'component/common/file-selector'; import Button from 'component/button'; import FileThumbnail from 'component/fileThumbnail'; +import * as MODALS from 'constants/modal_types'; +import { serializeFileObj } from 'util/file'; + type Props = { - params: UpdatePublishFormData, - progress: string, - xhr?: () => void, + uploadItem: FileUploadItem, + doPublishResume: (any) => void, + doUpdateUploadRemove: (any) => void, + doOpenModal: (string, {}) => void, }; export default function WebUploadItem(props: Props) { - const { params, progress, xhr } = props; + const { uploadItem, doPublishResume, doUpdateUploadRemove, doOpenModal } = props; + const { params, file, fileFingerprint, progress, status, tusUploader } = uploadItem; + + const [showFileSelector, setShowFileSelector] = useState(false); + + function handleFileChange(newFile: WebFile, clearName = true) { + if (serializeFileObj(newFile) === fileFingerprint) { + setShowFileSelector(false); + doPublishResume({ ...params, file_path: newFile }); + } else { + doOpenModal(MODALS.CONFIRM, { + title: __('Invalid file'), + subtitle: __('It appears to be a different or modified file.'), + body:

{__('Please select the same file from the initial upload.')}

, + onConfirm: (closeModal) => closeModal(), + hideCancel: true, + }); + } + } + + function handleCancel() { + doOpenModal(MODALS.CONFIRM, { + title: __('Cancel upload'), + subtitle: __('Cancel and remove the selected upload?'), + body: params.name ?

{`lbry://${params.name}`}

: undefined, + onConfirm: (closeModal) => { + if (tusUploader) { + tusUploader.abort(true); + } + doUpdateUploadRemove(params); + closeModal(); + }, + }); + } + + function resolveProgressStr() { + if (!tusUploader) { + return __('Stopped.'); + } else if (status) { + switch (status) { + case 'retry': + return __('Retrying...'); + case 'error': + return __('Failed.'); + default: + return status; + } + } else { + const progressInt = parseInt(progress); + return progressInt === 100 ? __('Processing...') : __('Uploading...'); + } + } + + function getRetryButton() { + if (!tusUploader) { + const isFileActive = file instanceof File; + return ( +