离线下载管理

This commit is contained in:
HFO4 2020-02-08 15:37:20 +08:00
parent 0589187157
commit 8dfb7fa8d0
11 changed files with 620 additions and 192 deletions

View File

@ -25,7 +25,8 @@
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.2.0",
"redux": "^4.0.4"
"redux": "^4.0.4",
"timeago-react": "^3.0.0"
},
"scripts": {
"start": "react-scripts start",

View File

@ -16,6 +16,7 @@ import PageLoading from "./component/Placeholder/PageLoading.js"
import TextViewer from "./component/Viewer/Text";
import DocViewer from "./component/Viewer/Doc";
import SharePreload from "./component/Share/SharePreload";
import Download from "./component/Download/Download";
// Lazy loads
const LoginForm = React.lazy(() => import("./component/Login/LoginForm"));
@ -93,6 +94,12 @@ export default function App() {
</Suspense>
</AuthRoute>
<AuthRoute path={`${path}aria2`} isLogin={isLogin}>
<Suspense fallback={<PageLoading/>}>
<Download />
</Suspense>
</AuthRoute>
<Route path={`${path}login`}>
<Suspense fallback={<PageLoading/>}>
<LoginForm />

View File

@ -17,11 +17,11 @@ import {
WindowRestore,
Android
} from "mdi-material-ui";
import { toggleSnackbar } from "../actions/index";
import { toggleSnackbar } from "../../actions";
import axios from "axios";
import { sizeToString } from "../untils/index";
import { mediaType } from "../config";
import { sizeToString } from "../../untils";
import { mediaType } from "../../config";
import API, { baseURL } from "../../middleware/Api";
import {
withStyles,
Card,
@ -31,13 +31,9 @@ import {
Button,
IconButton
} from "@material-ui/core";
import DownloadingCard from "./DownloadingCard";
const styles = theme => ({
card: {
marginTop: "20px",
display: "flex",
justifyContent: "space-between"
},
actions: {
display: "flex"
},
@ -67,28 +63,6 @@ const styles = theme => ({
gird: {
marginTop: "30px"
},
iconContainer: {
width: "90px",
height: "90px",
padding: "29px",
marginTop: "6px",
paddingLeft: "35px",
[theme.breakpoints.down("md")]: {
display: "none"
}
},
content: {
width: "100%",
minWidth: 0
},
contentSide: {
minWidth: 0,
paddingTop: "24px",
paddingRight: "28px",
[theme.breakpoints.down("md")]: {
display: "none"
}
},
iconImgBig: {
color: "#d32f2f",
fontSize: "30px"
@ -204,7 +178,7 @@ const getIcon = (classes, name) => {
return iconBig;
};
class DownloadCompoment extends Component {
class DownloadComponent extends Component {
page = 0;
state = {
@ -222,21 +196,20 @@ class DownloadCompoment extends Component {
this.setState({
loading: true
});
axios
.get("/RemoteDownload/FlushUser")
API.get("/aria2/downloading")
.then(response => {
axios.post("/RemoteDownload/ListDownloading").then(response => {
this.setState({
downloading: response.data,
loading: false
});
this.setState({
downloading: response.data,
loading: false
});
})
.catch(error => {
this.props.toggleSnackbar("top", "right", "加载失败", "error");
this.setState({
loading: false
});
this.props.toggleSnackbar(
"top",
"right",
error.message,
"error"
);
});
};
@ -316,70 +289,9 @@ class DownloadCompoment extends Component {
<RefreshIcon />
</IconButton>
</Typography>
{this.state.downloading.map(value => {
value.percent = !value.hasOwnProperty("completedLength")
? 0
: value.completedLength / value.totalLength;
return (
<Card className={classes.card} key={value.id}>
<div className={classes.iconContainer}>
{getIcon(classes, value.fileName)}
</div>
<CardContent className={classes.content}>
<Typography color="primary" variant="h6" noWrap>
{value.fileName}
</Typography>
<LinearProgress
color="secondary"
variant="determinate"
value={value.percent * 100}
/>
<Typography
variant="subtitle1"
color="textSecondary"
noWrap
>
{value.hasOwnProperty(
"completedLength"
) && (
<span>
{(value.percent * 100).toFixed(2)}%
-{" "}
{value.completedLength === "0"
? "0Bytes"
: sizeToString(
value.completedLength
)}
/
{value.totalLength === "0"
? "0Bytes"
: sizeToString(
value.totalLength
)}{" "}
-{" "}
{value.downloadSpeed === "0"
? "0B/s"
: sizeToString(
value.downloadSpeed
) + "/s"}
</span>
)}
{!value.hasOwnProperty(
"completedLength"
) && <span> - </span>}
</Typography>
</CardContent>
<CardContent
className={classes.contentSide}
onClick={() => this.cancelDownload(value.id)}
>
<IconButton>
<DeleteIcon />
</IconButton>
</CardContent>
</Card>
);
})}
{this.state.downloading.map((value, k) => (
<DownloadingCard key={ k } task={value} />
))}
<Typography
color="textSecondary"
variant="h4"
@ -450,6 +362,6 @@ class DownloadCompoment extends Component {
const Download = connect(
mapStateToProps,
mapDispatchToProps
)(withStyles(styles)(DownloadCompoment));
)(withStyles(styles)(DownloadComponent));
export default Download;

View File

@ -0,0 +1,560 @@
import React, {useState, useCallback, useEffect} from "react";
import {
Card,
CardContent,
darken,
IconButton,
lighten,
LinearProgress,
makeStyles,
Typography,
useTheme
} from "@material-ui/core";
import { useDispatch } from "react-redux";
import { toggleSnackbar } from "../../actions";
import {hex2bin, sizeToString} from "../../untils";
import PermMediaIcon from '@material-ui/icons/PermMedia';
import TypeIcon from "../FileManager/TypeIcon";
import MuiExpansionPanel from "@material-ui/core/ExpansionPanel";
import MuiExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary";
import MuiExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails";
import withStyles from "@material-ui/core/styles/withStyles";
import Divider from "@material-ui/core/Divider";
import { ExpandMore, HighlightOff} from "@material-ui/icons";
import classNames from "classnames";
import TableRow from "@material-ui/core/TableRow";
import TableCell from "@material-ui/core/TableCell";
import TableBody from "@material-ui/core/TableBody";
import Table from "@material-ui/core/Table";
import Badge from "@material-ui/core/Badge";
import Tooltip from "@material-ui/core/Tooltip";
import API, { baseURL } from "../../middleware/Api";
import Button from "@material-ui/core/Button";
import Grid from "@material-ui/core/Grid";
import TimeAgo from 'timeago-react';
const ExpansionPanel = withStyles({
root: {
maxWidth: "100%",
boxShadow: "none",
"&:not(:last-child)": {
borderBottom: 0
},
"&:before": {
display: "none"
},
"&$expanded": {}
},
expanded: {}
})(MuiExpansionPanel);
const ExpansionPanelSummary = withStyles({
root: {
minHeight: 0,
padding: 0,
"&$expanded": {
minHeight: 56
}
},
content: {
maxWidth: "100%",
margin: 0,
display: "flex",
"&$expanded": {
margin: "0"
}
},
expanded: {}
})(MuiExpansionPanelSummary);
const ExpansionPanelDetails = withStyles(theme => ({
root: {
display: "block",
padding: theme.spacing(0)
}
}))(MuiExpansionPanelDetails);
const useStyles = makeStyles(theme => ({
card: {
marginTop: "20px",
justifyContent: "space-between"
},
iconContainer: {
width: "90px",
height: "96px",
padding: " 35px 29px 29px 29px",
paddingLeft: "35px",
[theme.breakpoints.down("sm")]: {
display: "none"
}
},
content: {
width: "100%",
minWidth: 0,
[theme.breakpoints.up("sm")]: {
borderInlineStart: "1px " + theme.palette.divider + " solid"
}
},
contentSide: {
minWidth: 0,
paddingTop: "24px",
paddingRight: "28px",
[theme.breakpoints.down("sm")]: {
display: "none"
}
},
iconBig: {
fontSize: "30px"
},
iconMultiple:{
fontSize: "30px",
color:"#607D8B",
},
progress: {
marginTop: 8,
marginBottom: 4
},
expand: {
transition: ".15s transform ease-in-out"
},
expanded: {
transform: "rotate(180deg)"
},
subFileName: {
display: "flex"
},
subFileIcon: {
marginRight: "20px"
},
scroll: {
overflowY: "auto"
},
action:{
padding:theme.spacing(2),
textAlign:"right",
},
actionButton:{
marginLeft: theme.spacing(1),
},
info:{
padding:theme.spacing(2),
},
infoTitle:{
fontWeight:700,
},
infoValue:{
color:theme.palette.text.secondary,
},
bitmap:{
width: "100%",
height: "50px",
backgroundColor:theme.palette.background.default,
}
}));
export default function DownloadingCard(props) {
let canvasRef = React.createRef();
const classes = useStyles();
const theme = useTheme();
const [expanded, setExpanded] = React.useState("");
const [task, setTask] = React.useState(props.task);
const [loading, setLoading] = React.useState(false);
const handleChange = panel => (event, newExpanded) => {
setExpanded(newExpanded ? panel : false);
};
const dispatch = useDispatch();
const ToggleSnackbar = useCallback(
(vertical, horizontal, msg, color) =>
dispatch(toggleSnackbar(vertical, horizontal, msg, color)),
[dispatch]
);
useEffect(()=>{
setTask(props.task);
},[props.task]);
useEffect(()=>{
if (task.info.bitfield===""){
return
}
let result = "";
task.info.bitfield.match(/.{1,2}/g).forEach(str => {
result += hex2bin(str);
});
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = theme.palette.primary.main;
for (let i = 0;i<canvas.width;i++){
let bit = result[Math.round(((i+1)/canvas.width)*result.length)];
bit = bit?bit:result.slice(-1);
if(bit === "1"){
context.beginPath();
context.moveTo(i,0);
context.lineTo(i, canvas.height);
context.stroke();
}
}
},[task.info.bitfield,theme]);
const getPercent = (completed, total) => {
if (total == 0) {
return 0;
}
return (completed / total) * 100;
};
const deleteFile = (index)=>{
setLoading(true);
let current = activeFiles();
let newIndex = [];
let newFiles = [];
current.map((v)=>{
if (v.index !== index && v.selected){
newIndex.push(parseInt(v.index));
newFiles.push({
...v,
selected:"true",
});
}else{
newFiles.push({
...v,
selected:"false",
});
}
});
API.put("/aria2/select/"+task.info.gid,{
indexes:newIndex,
})
.then(response => {
setTask({
...task,
info:{
...task.info,
files:newFiles,
}
});
ToggleSnackbar(
"top",
"right",
"文件已删除",
"success"
);
})
.catch(error => {
ToggleSnackbar(
"top",
"right",
error.message,
"error"
);
}).finally(()=>{
setLoading(false);
});
};
const getDownloadName = useCallback(() => {
if (task.info.bittorrent.info.name !== "") {
return task.info.bittorrent.info.name;
}
return task.name === "." ? "[未知]" : task.name;
}, [task]);
const activeFiles = useCallback(() => {
return task.info.files.filter((v)=> v.selected==="true");
}, [task.info.files]);
const getIcon = useCallback(() => {
if (task.info.bittorrent.mode === "multi") {
return (
<Badge badgeContent={activeFiles().length} color="secondary">
<PermMediaIcon className={classes.iconMultiple}/>
</Badge>
)
} else {
return (
<TypeIcon
className={classes.iconBig}
fileName={getDownloadName(task)}
/>
);
}
}, [task, classes]);
return (
<Card className={classes.card}>
<ExpansionPanel
square
expanded={expanded === task.info.gid}
onChange={handleChange(task.info.gid)}
>
<ExpansionPanelSummary
aria-controls="panel1d-content"
id="panel1d-header"
>
<div className={classes.iconContainer}>{getIcon()}</div>
<CardContent className={classes.content}>
<Typography color="primary" noWrap>
<Tooltip title={getDownloadName()}>
<span>
{getDownloadName()}</span>
</Tooltip>
</Typography>
<LinearProgress
color="secondary"
variant="determinate"
className={classes.progress}
value={getPercent(
task.downloaded,
task.total
)}
/>
<Typography
variant="body2"
color="textSecondary"
noWrap
>
{task.total > 0 && (
<span>
{getPercent(
task.downloaded,
task.total
).toFixed(2)}
% -{" "}
{task.downloaded === 0
? "0Bytes"
: sizeToString(task.downloaded)}
/
{task.total === 0
? "0Bytes"
: sizeToString(task.total)}{" "}
-{" "}
{task.speed === "0"
? "0B/s"
: sizeToString(task.speed) + "/s"}
</span>
)}
{task.total === 0 && <span> - </span>}
</Typography>
</CardContent>
<CardContent className={classes.contentSide}>
<IconButton>
<ExpandMore
className={classNames(
{
[classes.expanded]:
expanded === task.info.gid
},
classes.expand
)}
/>
</IconButton>
</CardContent>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<Divider />
<div className={classes.scroll}>
<Table size="small">
<TableBody>
{activeFiles().map((value, key) => {
return (
<TableRow
key={value.index}
style={{
background:
"linear-gradient(to right, " +
(theme.palette.type ===
"dark"
? darken(
theme.palette
.primary.main,
0.4
)
: lighten(
theme.palette
.primary.main,
0.85
)) +
" 0%," +
(theme.palette.type ===
"dark"
? darken(
theme.palette
.primary.main,
0.4
)
: lighten(
theme.palette
.primary.main,
0.85
)) +
" " +
getPercent(
value.completedLength,
value.length
).toFixed(0) +
"%," +
theme.palette.background
.paper +
" " +
getPercent(
value.completedLength,
value.length
).toFixed(0) +
"%," +
theme.palette.background
.paper +
" 100%)"
}}
>
<TableCell
component="th"
scope="row"
>
<Typography
className={
classes.subFileName
}
>
<TypeIcon
className={
classes.subFileIcon
}
fileName={value.path}
/>
{value.path}
</Typography>
</TableCell>
<TableCell
component="th"
scope="row"
>
<Typography noWrap>
{" "}
{sizeToString(value.length)}
</Typography>
</TableCell>
<TableCell
component="th"
scope="row"
>
<Typography noWrap>
{getPercent(
value.completedLength,
value.length
).toFixed(2)}
%
</Typography>
</TableCell>
<TableCell>
<Tooltip title="删除此文件">
<IconButton onClick={()=>deleteFile(value.index)} disabled={loading} size={"small"}>
<HighlightOff/>
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<Divider/>
</div>
<div className={classes.action}>
<Button className={classes.actionButton} variant="outlined" color="secondary">
选择要下载的文件
</Button>
<Button className={classes.actionButton} variant="contained" color="secondary">
取消任务
</Button>
</div>
<Divider/>
<div className={classes.info}>
{task.info.bitfield !==""&&<canvas width={"700"} height={"100"} ref={canvasRef} className={classes.bitmap}/>}
<Grid container >
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
更新于
</Grid>
<Grid item xs={8} className={classes.infoValue}>
<TimeAgo
datetime={parseInt(task.update + "000")}
locale='zh_CN'
/>
</Grid>
</Grid>
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
上传大小
</Grid>
<Grid item xs={8} className={classes.infoValue}>
{sizeToString(task.info.uploadLength)}
</Grid>
</Grid>
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
上传速度
</Grid>
<Grid item xs={8} className={classes.infoValue}>
{sizeToString(task.info.uploadLength)} / s
</Grid>
</Grid>
{task.info.bittorrent.mode !== ""&&
<><Grid container xs={12} sm={8} >
<Grid item xs={2} className={classes.infoTitle}>
InfoHash
</Grid>
<Grid item xs={10} className={classes.infoValue}>
{task.info.infoHash}
</Grid>
</Grid>
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
做种者
</Grid>
<Grid item xs={8} className={classes.infoValue}>
{task.info.numSeeders}
</Grid>
</Grid>
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
做种中
</Grid>
<Grid item xs={8} className={classes.infoValue}>
{task.info.seeder === "true"?"是":"否"}
</Grid>
</Grid>
</>
}
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
分片大小
</Grid>
<Grid item xs={8} className={classes.infoValue}>
{sizeToString(task.info.pieceLength)}
</Grid>
</Grid>
<Grid container xs={12} sm={4} >
<Grid item xs={4} className={classes.infoTitle}>
分片数量
</Grid>
<Grid item xs={8} className={classes.infoValue}>
{task.info.numPieces}
</Grid>
</Grid>
</Grid>
</div>
</ExpansionPanelDetails>
</ExpansionPanel>
</Card>
);
}

View File

@ -411,9 +411,8 @@ class ContextMenuCompoment extends Component {
{!this.props.isMultiple &&
isHomePage &&
user.group.allowTorrentDownload &&
this.props.withFile &&
isTorrent(this.props.selected[0].name) && (
user.group.allowRemoteDownload &&
this.props.withFile&& (
<MenuItem
onClick={() =>
this.props.openTorrentDownloadDialog()

View File

@ -10,7 +10,6 @@ import {
openLoadingDialog
} from "../../actions/index";
import PathSelector from "./PathSelector";
import axios from "axios";
import API, { baseURL } from "../../middleware/Api";
import {
withStyles,
@ -22,9 +21,6 @@ import {
DialogTitle,
DialogContentText,
CircularProgress,
Checkbox,
FormControl,
FormControlLabel
} from "@material-ui/core";
import Loading from "../Modals/Loading";
import CopyDialog from "../Modals/Copy";
@ -35,6 +31,7 @@ import PurchaseShareDialog from "../Modals/PurchaseShare";
import Auth from "../../middleware/Auth";
import DecompressDialog from "../Modals/Decompress";
import CompressDialog from "../Modals/Compress";
import {filePath} from "../../untils";
const styles = theme => ({
wrapper: {
@ -283,48 +280,6 @@ class ModalsCompoment extends Component {
});
};
submitShare = e => {
e.preventDefault();
this.props.setModalsLoading(true);
axios
.post("/File/Share", {
action: "share",
item:
this.props.selected[0].path === "/"
? this.props.selected[0].path +
this.props.selected[0].name
: this.props.selected[0].path +
"/" +
this.props.selected[0].name,
shareType: this.state.secretShare ? "private" : "public",
pwd: this.state.sharePwd
})
.then(response => {
if (response.data.result !== "") {
this.setState({
shareUrl: response.data.result
});
} else {
this.props.toggleSnackbar(
"top",
"right",
response.data.result.error,
"warning"
);
}
this.props.setModalsLoading(false);
})
.catch(error => {
this.props.toggleSnackbar(
"top",
"right",
error.message,
"error"
);
this.props.setModalsLoading(false);
});
};
submitRemove = e => {
e.preventDefault();
this.props.setModalsLoading(true);
@ -575,14 +530,11 @@ class ModalsCompoment extends Component {
submitTorrentDownload = e => {
e.preventDefault();
this.props.setModalsLoading(true);
axios
.post("/RemoteDownload/AddTorrent", {
action: "torrentDownload",
id: this.props.selected[0].id,
savePath: this.state.selectedPath
API
.post("/aria2/torrent" + filePath(this.props.selected[0]), {
dst: this.state.selectedPath === "//" ? "/" : this.state.selectedPath
})
.then(response => {
if (response.data.result.success) {
this.props.toggleSnackbar(
"top",
"right",
@ -590,14 +542,6 @@ class ModalsCompoment extends Component {
"success"
);
this.onClose();
} else {
this.props.toggleSnackbar(
"top",
"right",
response.data.result.error,
"warning"
);
}
this.props.setModalsLoading(false);
})
.catch(error => {
@ -614,14 +558,12 @@ class ModalsCompoment extends Component {
submitDownload = e => {
e.preventDefault();
this.props.setModalsLoading(true);
axios
.post("/RemoteDownload/addUrl", {
action: "remoteDownload",
API
.post("/aria2/url", {
url: this.state.downloadURL,
path: this.state.selectedPath
dst: this.state.selectedPath === "//" ? "/" : this.state.selectedPath
})
.then(response => {
if (response.data.result.success) {
this.props.toggleSnackbar(
"top",
"right",
@ -629,14 +571,6 @@ class ModalsCompoment extends Component {
"success"
);
this.onClose();
} else {
this.props.toggleSnackbar(
"top",
"right",
response.data.result.error,
"warning"
);
}
this.props.setModalsLoading(false);
})
.catch(error => {

View File

@ -528,9 +528,7 @@ class NavbarCompoment extends Component {
<ListItem
button
key="离线下载"
onClick={() =>
(window.location.href = "/Home/Download")
}
onClick={() => (this.props.history.push("/aria2?"))}
>
<ListItemIcon>
<DownloadIcon className={classes.iconFix} />

View File

@ -13,9 +13,10 @@ const instance = axios.create({
crossDomain: true
});
function AppError(message,code) {
function AppError(message,code,error) {
this.code = code;
this.message = message || '未知错误';
this.message += error?(" "+error) : "";
this.stack = (new Error()).stack;
}
AppError.prototype = Object.create(Error.prototype);
@ -35,7 +36,7 @@ instance.interceptors.response.use(
Auth.signout();
window.location.href = "#/Login";
}
throw new AppError(response.rawData.msg,response.rawData.code);
throw new AppError(response.rawData.msg,response.rawData.code,response.rawData.error);
}
return response;
},

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import Navbar from "../component/Navbar/Navbar.js"
import AlertBar from "../component/Snackbar"
import { createMuiTheme } from '@material-ui/core/styles';
import Download from '../component/Download'
import Download from '../component/Download/Download'
import { CssBaseline, withStyles, MuiThemeProvider } from '@material-ui/core';
const theme = createMuiTheme(window.colorTheme);
const styles = theme => ({

View File

@ -1,5 +1,5 @@
export const sizeToString = bytes => {
if (bytes === 0) return "0 B";
if (bytes === 0 || bytes==="0") return "0 B";
var k = 1024;
var sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
var i = Math.floor(Math.log(bytes) / Math.log(k));
@ -131,3 +131,7 @@ export function filePath(file) {
? file.path + file.name
: file.path + "/" + file.name;
}
export function hex2bin(hex){
return (parseInt(hex, 16).toString(2)).padStart(8, '0');
}

View File

@ -10022,6 +10022,18 @@ thunky@^1.0.2:
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.0.3.tgz#f5df732453407b09191dae73e2a8cc73f381a826"
integrity sha512-YwT8pjmNcAXBZqrubu22P4FYsh2D4dxRmnWBOL8Jk8bUcRUtc5326kx32tuTmFDAZtLOGEVNl8POAR8j896Iow==
timeago-react@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/timeago-react/-/timeago-react-3.0.0.tgz#777665f768ae0517e71be137fc6287c4d0a6a788"
integrity sha512-dO7dBjuRqUSoyt7kLc6UJLvfN5F0JbC2qTqIsjpRFaMXu9bK9PKuWQ3/XD/kPZWGVtwpNimKDpQC3Q2ok8MMSA==
dependencies:
timeago.js "^4.0.0"
timeago.js@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/timeago.js/-/timeago.js-4.0.2.tgz#724e8c8833e3490676c7bb0a75f5daf20e558028"
integrity sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==
timers-browserify@^2.0.4:
version "2.0.11"
resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f"