Download REST API endpoints (#72)
* backend and frontend hotfixes, see message Improved rendering on the frontend by cutting unecessary useStates. Backend side, downloads now auto resume even on application kill. * download rest api endpoints, general code refactor * download request json mappings
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
import { ThemeProvider } from '@emotion/react'
|
||||
|
||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||
import Dashboard from '@mui/icons-material/Dashboard'
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
||||
import Storage from '@mui/icons-material/Storage'
|
||||
|
||||
import { Box, createTheme } from '@mui/material'
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Divider from '@mui/material/Divider'
|
||||
@@ -19,20 +16,16 @@ import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { grey } from '@mui/material/colors'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { RootState } from './stores/store'
|
||||
|
||||
import AppBar from './components/AppBar'
|
||||
import Drawer from './components/Drawer'
|
||||
|
||||
import Logout from './components/Logout'
|
||||
import ThemeToggler from './components/ThemeToggler'
|
||||
import Toaster from './providers/ToasterProvider'
|
||||
import I18nProvider from './providers/i18nProvider'
|
||||
import RPCClientProvider from './providers/rpcClientProvider'
|
||||
import { formatGiB } from './utils'
|
||||
@@ -184,6 +177,7 @@ export default function Layout() {
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
<Toaster />
|
||||
</RPCClientProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Grid, Snackbar } from "@mui/material"
|
||||
import { Fragment, useContext, useEffect, useState } from "react"
|
||||
import { Grid } from "@mui/material"
|
||||
import { Fragment, useContext } from "react"
|
||||
import { useToast } from "../hooks/toast"
|
||||
import { I18nContext } from "../providers/i18nProvider"
|
||||
import type { RPCResult } from "../types"
|
||||
import { StackableResult } from "./StackableResult"
|
||||
import { I18nContext } from "../providers/i18nProvider"
|
||||
|
||||
type Props = {
|
||||
downloads: RPCResult[]
|
||||
@@ -10,9 +11,8 @@ type Props = {
|
||||
}
|
||||
|
||||
export function DownloadsCardView({ downloads, onStop }: Props) {
|
||||
const [openSB, setOpenSB] = useState(false)
|
||||
|
||||
const { i18n } = useContext(I18nContext)
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
return (
|
||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||
@@ -26,7 +26,7 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
|
||||
thumbnail={download.info.thumbnail}
|
||||
percentage={download.progress.percentage}
|
||||
onStop={() => onStop(download.id)}
|
||||
onCopy={() => setOpenSB(true)}
|
||||
onCopy={() => pushMessage(i18n.t('clipboardAction'))}
|
||||
resolution={download.info.resolution ?? ''}
|
||||
speed={download.progress.speed}
|
||||
size={download.info.filesize_approx ?? 0}
|
||||
@@ -36,12 +36,6 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
|
||||
</Grid>
|
||||
))
|
||||
}
|
||||
<Snackbar
|
||||
open={openSB}
|
||||
autoHideDuration={1250}
|
||||
onClose={() => setOpenSB(false)}
|
||||
message={i18n.t('clipboardAction')}
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
@@ -19,13 +19,13 @@ type Props = {
|
||||
onStop: (id: string) => void
|
||||
}
|
||||
|
||||
export function DownloadsListView({ downloads, onStop }: Props) {
|
||||
export const DownloadsListView: React.FC<Props> = ({ downloads, onStop }) => {
|
||||
return (
|
||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||
<Grid item xs={12}>
|
||||
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
|
||||
<TableContainer component={Paper} sx={{ minHeight: '100%' }} elevation={2}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHead hidden={downloads.length === 0}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography fontWeight={500} fontSize={15}>Title</Typography>
|
||||
@@ -52,8 +52,9 @@ export function DownloadsListView({ downloads, onStop }: Props) {
|
||||
<TableCell>
|
||||
<LinearProgress
|
||||
value={
|
||||
download.progress.percentage === '-1' ? 100 :
|
||||
Number(download.progress.percentage.replace('%', ''))
|
||||
download.progress.percentage === '-1'
|
||||
? 100
|
||||
: Number(download.progress.percentage.replace('%', ''))
|
||||
}
|
||||
variant={
|
||||
download.progress.process_status === 0
|
||||
|
||||
46
frontend/src/features/ui/toastSlice.ts
Normal file
46
frontend/src/features/ui/toastSlice.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AlertColor } from '@mui/material'
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface ToastState {
|
||||
message: string
|
||||
open: boolean
|
||||
autoClose: boolean
|
||||
severity?: AlertColor
|
||||
}
|
||||
|
||||
type MessageAction = {
|
||||
message: string,
|
||||
severity?: AlertColor
|
||||
}
|
||||
|
||||
const initialState: ToastState = {
|
||||
message: '',
|
||||
open: false,
|
||||
autoClose: true,
|
||||
}
|
||||
|
||||
export const toastSlice = createSlice({
|
||||
name: 'toast',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMessage: (state, action: PayloadAction<MessageAction>) => {
|
||||
state.message = action.payload.message
|
||||
state.severity = action.payload.severity
|
||||
state.open = true
|
||||
},
|
||||
setOpen: (state) => {
|
||||
state.open = true
|
||||
},
|
||||
setClose: (state) => {
|
||||
state.open = false
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
setMessage,
|
||||
setClose,
|
||||
setOpen,
|
||||
} = toastSlice.actions
|
||||
|
||||
export default toastSlice.reducer
|
||||
16
frontend/src/hooks/toast.ts
Normal file
16
frontend/src/hooks/toast.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useDispatch } from "react-redux"
|
||||
import { setMessage } from "../features/ui/toastSlice"
|
||||
import { AlertColor } from "@mui/material"
|
||||
|
||||
export const useToast = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return {
|
||||
pushMessage: (message: string, severity?: AlertColor) => {
|
||||
dispatch(setMessage({
|
||||
message: message,
|
||||
severity: severity
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/src/providers/ToasterProvider.tsx
Normal file
23
frontend/src/providers/ToasterProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Alert, Snackbar } from "@mui/material"
|
||||
import { useDispatch, useSelector } from "react-redux"
|
||||
import { setClose } from "../features/ui/toastSlice"
|
||||
import { RootState } from "../stores/store"
|
||||
|
||||
const Toaster: React.FC = () => {
|
||||
const toast = useSelector((state: RootState) => state.toast)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={toast.open}
|
||||
autoHideDuration={toast.severity === 'error' ? 10000 : 1500}
|
||||
onClose={() => dispatch(setClose())}
|
||||
>
|
||||
<Alert variant="filled" severity={toast.severity}>
|
||||
{toast.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toaster
|
||||
@@ -1,12 +1,14 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import settingsReducer from '../features/settings/settingsSlice'
|
||||
import statussReducer from '../features/status/statusSlice'
|
||||
import toastReducer from '../features/ui/toastSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
settings: settingsReducer,
|
||||
status: statussReducer,
|
||||
},
|
||||
reducer: {
|
||||
settings: settingsReducer,
|
||||
status: statussReducer,
|
||||
toast: toastReducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
|
||||
@@ -2,11 +2,9 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
import {
|
||||
Alert,
|
||||
Backdrop,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Snackbar,
|
||||
SpeedDial,
|
||||
SpeedDialAction,
|
||||
SpeedDialIcon
|
||||
@@ -19,6 +17,7 @@ import { DownloadsListView } from '../components/DownloadsListView'
|
||||
import Splash from '../components/Splash'
|
||||
import { toggleListView } from '../features/settings/settingsSlice'
|
||||
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { socket$ } from '../lib/rpcClient'
|
||||
import { I18nContext } from '../providers/i18nProvider'
|
||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
||||
@@ -27,24 +26,19 @@ import type { RPCResponse, RPCResult } from '../types'
|
||||
import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
||||
|
||||
export default function Home() {
|
||||
// redux state
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// ephemeral state
|
||||
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
|
||||
|
||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||
const [showToast, setShowToast] = useState(true)
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [socketHasError, setSocketHasError] = useState(false)
|
||||
|
||||
// context
|
||||
const { i18n } = useContext(I18nContext)
|
||||
const { client } = useContext(RPCClientContext)
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
/* -------------------- Effects -------------------- */
|
||||
|
||||
/* WebSocket connect event handler*/
|
||||
@@ -56,13 +50,12 @@ export default function Home() {
|
||||
dispatch(connected())
|
||||
},
|
||||
error: () => {
|
||||
setSocketHasError(true)
|
||||
pushMessage(
|
||||
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
|
||||
"error"
|
||||
)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
complete: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
}
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [socket$, status.connected])
|
||||
@@ -80,7 +73,10 @@ export default function Home() {
|
||||
.freeSpace()
|
||||
.then(bytes => dispatch(setFreeSpace(bytes.result)))
|
||||
.catch(() => {
|
||||
setSocketHasError(true)
|
||||
pushMessage(
|
||||
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
|
||||
"error"
|
||||
)
|
||||
setShowBackdrop(false)
|
||||
})
|
||||
}, [])
|
||||
@@ -98,6 +94,12 @@ export default function Home() {
|
||||
a.info.created_at,
|
||||
)))
|
||||
})
|
||||
|
||||
pushMessage(
|
||||
`Connected to (${settings.serverAddr}:${settings.serverPort})`,
|
||||
"success"
|
||||
)
|
||||
|
||||
return () => sub.unsubscribe()
|
||||
}, [socket$, status.connected])
|
||||
|
||||
@@ -107,11 +109,6 @@ export default function Home() {
|
||||
}
|
||||
}, [activeDownloads?.length])
|
||||
|
||||
/**
|
||||
* Abort a specific download if id's provided, other wise abort all running ones.
|
||||
* @param id The download id / pid
|
||||
* @returns void
|
||||
*/
|
||||
const abort = (id?: string) => {
|
||||
if (id) {
|
||||
client.kill(id)
|
||||
@@ -136,20 +133,6 @@ export default function Home() {
|
||||
<DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> :
|
||||
<DownloadsCardView downloads={activeDownloads ?? []} onStop={abort} />
|
||||
}
|
||||
<Snackbar
|
||||
open={showToast === status.connected}
|
||||
autoHideDuration={1500}
|
||||
onClose={() => setShowToast(false)}
|
||||
>
|
||||
<Alert variant="filled" severity="success">
|
||||
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar open={socketHasError}>
|
||||
<Alert variant="filled" severity="error">
|
||||
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<SpeedDial
|
||||
ariaLabel="SpeedDial basic example"
|
||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
@@ -39,7 +38,7 @@ import {
|
||||
setServerPort,
|
||||
setTheme
|
||||
} from '../features/settings/settingsSlice'
|
||||
import { updated } from '../features/status/statusSlice'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { CliArguments } from '../lib/argsParser'
|
||||
import { I18nContext } from '../providers/i18nProvider'
|
||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
||||
@@ -49,7 +48,6 @@ import { validateDomain, validateIP } from '../utils'
|
||||
export default function Settings() {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
|
||||
const [invalidIP, setInvalidIP] = useState(false);
|
||||
@@ -57,6 +55,8 @@ export default function Settings() {
|
||||
const { i18n } = useContext(I18nContext)
|
||||
const { client } = useContext(RPCClientContext)
|
||||
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
|
||||
|
||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
||||
@@ -110,10 +110,10 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send via WebSocket a message to update yt-dlp binary
|
||||
* Updates yt-dlp binary via RPC
|
||||
*/
|
||||
const updateBinary = () => {
|
||||
client.updateExecutable().then(() => dispatch(updated()))
|
||||
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -270,7 +270,7 @@ export default function Settings() {
|
||||
<Button
|
||||
sx={{ mr: 1, mt: 3 }}
|
||||
variant="contained"
|
||||
onClick={() => dispatch(updated())}
|
||||
onClick={() => updateBinary()}
|
||||
>
|
||||
{i18n.t('updateBinButton')}
|
||||
</Button>
|
||||
@@ -280,12 +280,6 @@ export default function Settings() {
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Snackbar
|
||||
open={status.updated}
|
||||
autoHideDuration={1500}
|
||||
message={i18n.t('toastUpdated')}
|
||||
onClose={updateBinary}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user