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 { ThemeProvider } from '@emotion/react'
|
||||||
|
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||||
import Dashboard from '@mui/icons-material/Dashboard'
|
import Dashboard from '@mui/icons-material/Dashboard'
|
||||||
import Menu from '@mui/icons-material/Menu'
|
import Menu from '@mui/icons-material/Menu'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
|
||||||
import Storage from '@mui/icons-material/Storage'
|
import Storage from '@mui/icons-material/Storage'
|
||||||
|
|
||||||
import { Box, createTheme } from '@mui/material'
|
import { Box, createTheme } from '@mui/material'
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download'
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
@@ -19,20 +16,16 @@ import ListItemIcon from '@mui/material/ListItemIcon'
|
|||||||
import ListItemText from '@mui/material/ListItemText'
|
import ListItemText from '@mui/material/ListItemText'
|
||||||
import Toolbar from '@mui/material/Toolbar'
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
import { grey } from '@mui/material/colors'
|
import { grey } from '@mui/material/colors'
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { RootState } from './stores/store'
|
import { RootState } from './stores/store'
|
||||||
|
|
||||||
import AppBar from './components/AppBar'
|
import AppBar from './components/AppBar'
|
||||||
import Drawer from './components/Drawer'
|
import Drawer from './components/Drawer'
|
||||||
|
|
||||||
import Logout from './components/Logout'
|
import Logout from './components/Logout'
|
||||||
import ThemeToggler from './components/ThemeToggler'
|
import ThemeToggler from './components/ThemeToggler'
|
||||||
|
import Toaster from './providers/ToasterProvider'
|
||||||
import I18nProvider from './providers/i18nProvider'
|
import I18nProvider from './providers/i18nProvider'
|
||||||
import RPCClientProvider from './providers/rpcClientProvider'
|
import RPCClientProvider from './providers/rpcClientProvider'
|
||||||
import { formatGiB } from './utils'
|
import { formatGiB } from './utils'
|
||||||
@@ -184,6 +177,7 @@ export default function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Toaster />
|
||||||
</RPCClientProvider>
|
</RPCClientProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Grid, Snackbar } from "@mui/material"
|
import { Grid } from "@mui/material"
|
||||||
import { Fragment, useContext, useEffect, useState } from "react"
|
import { Fragment, useContext } from "react"
|
||||||
|
import { useToast } from "../hooks/toast"
|
||||||
|
import { I18nContext } from "../providers/i18nProvider"
|
||||||
import type { RPCResult } from "../types"
|
import type { RPCResult } from "../types"
|
||||||
import { StackableResult } from "./StackableResult"
|
import { StackableResult } from "./StackableResult"
|
||||||
import { I18nContext } from "../providers/i18nProvider"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
downloads: RPCResult[]
|
downloads: RPCResult[]
|
||||||
@@ -10,9 +11,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadsCardView({ downloads, onStop }: Props) {
|
export function DownloadsCardView({ downloads, onStop }: Props) {
|
||||||
const [openSB, setOpenSB] = useState(false)
|
|
||||||
|
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useContext(I18nContext)
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
<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}
|
thumbnail={download.info.thumbnail}
|
||||||
percentage={download.progress.percentage}
|
percentage={download.progress.percentage}
|
||||||
onStop={() => onStop(download.id)}
|
onStop={() => onStop(download.id)}
|
||||||
onCopy={() => setOpenSB(true)}
|
onCopy={() => pushMessage(i18n.t('clipboardAction'))}
|
||||||
resolution={download.info.resolution ?? ''}
|
resolution={download.info.resolution ?? ''}
|
||||||
speed={download.progress.speed}
|
speed={download.progress.speed}
|
||||||
size={download.info.filesize_approx ?? 0}
|
size={download.info.filesize_approx ?? 0}
|
||||||
@@ -36,12 +36,6 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
|
|||||||
</Grid>
|
</Grid>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
<Snackbar
|
|
||||||
open={openSB}
|
|
||||||
autoHideDuration={1250}
|
|
||||||
onClose={() => setOpenSB(false)}
|
|
||||||
message={i18n.t('clipboardAction')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -19,13 +19,13 @@ type Props = {
|
|||||||
onStop: (id: string) => void
|
onStop: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadsListView({ downloads, onStop }: Props) {
|
export const DownloadsListView: React.FC<Props> = ({ downloads, onStop }) => {
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
|
<TableContainer component={Paper} sx={{ minHeight: '100%' }} elevation={2}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead hidden={downloads.length === 0}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography fontWeight={500} fontSize={15}>Title</Typography>
|
<Typography fontWeight={500} fontSize={15}>Title</Typography>
|
||||||
@@ -52,8 +52,9 @@ export function DownloadsListView({ downloads, onStop }: Props) {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
value={
|
value={
|
||||||
download.progress.percentage === '-1' ? 100 :
|
download.progress.percentage === '-1'
|
||||||
Number(download.progress.percentage.replace('%', ''))
|
? 100
|
||||||
|
: Number(download.progress.percentage.replace('%', ''))
|
||||||
}
|
}
|
||||||
variant={
|
variant={
|
||||||
download.progress.process_status === 0
|
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,11 +1,13 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit'
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
import settingsReducer from '../features/settings/settingsSlice'
|
import settingsReducer from '../features/settings/settingsSlice'
|
||||||
import statussReducer from '../features/status/statusSlice'
|
import statussReducer from '../features/status/statusSlice'
|
||||||
|
import toastReducer from '../features/ui/toastSlice'
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
status: statussReducer,
|
status: statussReducer,
|
||||||
|
toast: toastReducer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'
|
|||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Backdrop,
|
Backdrop,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
Snackbar,
|
|
||||||
SpeedDial,
|
SpeedDial,
|
||||||
SpeedDialAction,
|
SpeedDialAction,
|
||||||
SpeedDialIcon
|
SpeedDialIcon
|
||||||
@@ -19,6 +17,7 @@ import { DownloadsListView } from '../components/DownloadsListView'
|
|||||||
import Splash from '../components/Splash'
|
import Splash from '../components/Splash'
|
||||||
import { toggleListView } from '../features/settings/settingsSlice'
|
import { toggleListView } from '../features/settings/settingsSlice'
|
||||||
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
import { socket$ } from '../lib/rpcClient'
|
import { socket$ } from '../lib/rpcClient'
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
import { I18nContext } from '../providers/i18nProvider'
|
||||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
import { RPCClientContext } from '../providers/rpcClientProvider'
|
||||||
@@ -27,24 +26,19 @@ import type { RPCResponse, RPCResult } from '../types'
|
|||||||
import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
import { datetimeCompareFunc, isRPCResponse } from '../utils'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// redux state
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
const status = useSelector((state: RootState) => state.status)
|
const status = useSelector((state: RootState) => state.status)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
// ephemeral state
|
|
||||||
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
|
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
|
||||||
|
|
||||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||||
const [showToast, setShowToast] = useState(true)
|
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
const [socketHasError, setSocketHasError] = useState(false)
|
|
||||||
|
|
||||||
// context
|
|
||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useContext(I18nContext)
|
||||||
const { client } = useContext(RPCClientContext)
|
const { client } = useContext(RPCClientContext)
|
||||||
|
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
/* -------------------- Effects -------------------- */
|
/* -------------------- Effects -------------------- */
|
||||||
|
|
||||||
/* WebSocket connect event handler*/
|
/* WebSocket connect event handler*/
|
||||||
@@ -56,13 +50,12 @@ export default function Home() {
|
|||||||
dispatch(connected())
|
dispatch(connected())
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
setSocketHasError(true)
|
pushMessage(
|
||||||
|
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
setShowBackdrop(false)
|
setShowBackdrop(false)
|
||||||
},
|
}
|
||||||
complete: () => {
|
|
||||||
setSocketHasError(true)
|
|
||||||
setShowBackdrop(false)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
return () => sub.unsubscribe()
|
return () => sub.unsubscribe()
|
||||||
}, [socket$, status.connected])
|
}, [socket$, status.connected])
|
||||||
@@ -80,7 +73,10 @@ export default function Home() {
|
|||||||
.freeSpace()
|
.freeSpace()
|
||||||
.then(bytes => dispatch(setFreeSpace(bytes.result)))
|
.then(bytes => dispatch(setFreeSpace(bytes.result)))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setSocketHasError(true)
|
pushMessage(
|
||||||
|
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
setShowBackdrop(false)
|
setShowBackdrop(false)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -98,6 +94,12 @@ export default function Home() {
|
|||||||
a.info.created_at,
|
a.info.created_at,
|
||||||
)))
|
)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
pushMessage(
|
||||||
|
`Connected to (${settings.serverAddr}:${settings.serverPort})`,
|
||||||
|
"success"
|
||||||
|
)
|
||||||
|
|
||||||
return () => sub.unsubscribe()
|
return () => sub.unsubscribe()
|
||||||
}, [socket$, status.connected])
|
}, [socket$, status.connected])
|
||||||
|
|
||||||
@@ -107,11 +109,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [activeDownloads?.length])
|
}, [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) => {
|
const abort = (id?: string) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
client.kill(id)
|
client.kill(id)
|
||||||
@@ -136,20 +133,6 @@ export default function Home() {
|
|||||||
<DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> :
|
<DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> :
|
||||||
<DownloadsCardView 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
|
<SpeedDial
|
||||||
ariaLabel="SpeedDial basic example"
|
ariaLabel="SpeedDial basic example"
|
||||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
Snackbar,
|
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -39,7 +38,7 @@ import {
|
|||||||
setServerPort,
|
setServerPort,
|
||||||
setTheme
|
setTheme
|
||||||
} from '../features/settings/settingsSlice'
|
} from '../features/settings/settingsSlice'
|
||||||
import { updated } from '../features/status/statusSlice'
|
import { useToast } from '../hooks/toast'
|
||||||
import { CliArguments } from '../lib/argsParser'
|
import { CliArguments } from '../lib/argsParser'
|
||||||
import { I18nContext } from '../providers/i18nProvider'
|
import { I18nContext } from '../providers/i18nProvider'
|
||||||
import { RPCClientContext } from '../providers/rpcClientProvider'
|
import { RPCClientContext } from '../providers/rpcClientProvider'
|
||||||
@@ -49,7 +48,6 @@ import { validateDomain, validateIP } from '../utils'
|
|||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
const status = useSelector((state: RootState) => state.status)
|
|
||||||
const settings = useSelector((state: RootState) => state.settings)
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
|
|
||||||
const [invalidIP, setInvalidIP] = useState(false);
|
const [invalidIP, setInvalidIP] = useState(false);
|
||||||
@@ -57,6 +55,8 @@ export default function Settings() {
|
|||||||
const { i18n } = useContext(I18nContext)
|
const { i18n } = useContext(I18nContext)
|
||||||
const { client } = useContext(RPCClientContext)
|
const { client } = useContext(RPCClientContext)
|
||||||
|
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
|
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
|
||||||
|
|
||||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
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 = () => {
|
const updateBinary = () => {
|
||||||
client.updateExecutable().then(() => dispatch(updated()))
|
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,7 +270,7 @@ export default function Settings() {
|
|||||||
<Button
|
<Button
|
||||||
sx={{ mr: 1, mt: 3 }}
|
sx={{ mr: 1, mt: 3 }}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => dispatch(updated())}
|
onClick={() => updateBinary()}
|
||||||
>
|
>
|
||||||
{i18n.t('updateBinButton')}
|
{i18n.t('updateBinButton')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -280,12 +280,6 @@ export default function Settings() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Snackbar
|
|
||||||
open={status.updated}
|
|
||||||
autoHideDuration={1500}
|
|
||||||
message={i18n.t('toastUpdated')}
|
|
||||||
onClose={updateBinary}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// FG
|
// FG
|
||||||
Red = "\033[31m"
|
Red = "\033[31m"
|
||||||
@@ -15,9 +13,5 @@ const (
|
|||||||
BgRed = "\033[1;41m"
|
BgRed = "\033[1;41m"
|
||||||
BgBlue = "\033[1;44m"
|
BgBlue = "\033[1;44m"
|
||||||
BgGreen = "\033[1;42m"
|
BgGreen = "\033[1;42m"
|
||||||
|
BgMagenta = "\033[1;45m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Formats a message with the specified ascii escape code, then reset.
|
|
||||||
func Format(message string, code string) string {
|
|
||||||
return fmt.Sprintf("%s%s%s", code, message, Reset)
|
|
||||||
}
|
|
||||||
|
|||||||
157
server/handlers/archive.go
Normal file
157
server/handlers/archive.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TOKEN_COOKIE_NAME = "jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DirectoryEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHASum string `json:"shaSum"`
|
||||||
|
ModTime time.Time `json:"modTime"`
|
||||||
|
IsVideo bool `json:"isVideo"`
|
||||||
|
IsDirectory bool `json:"isDirectory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkDir(root string) (*[]DirectoryEntry, error) {
|
||||||
|
files := []DirectoryEntry{}
|
||||||
|
|
||||||
|
dirs, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range dirs {
|
||||||
|
if !utils.IsValidEntry(d) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(root, d.Name())
|
||||||
|
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, DirectoryEntry{
|
||||||
|
Path: path,
|
||||||
|
Name: d.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
SHASum: utils.ShaSumString(path),
|
||||||
|
IsVideo: utils.IsVideo(d),
|
||||||
|
IsDirectory: d.IsDir(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRequest struct {
|
||||||
|
SubDir string `json:"subdir"`
|
||||||
|
OrderBy string `json:"orderBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
|
||||||
|
root := config.Instance().GetConfig().DownloadPath
|
||||||
|
req := new(ListRequest)
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := walkDir(filepath.Join(root, req.SubDir))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrderBy == "modtime" {
|
||||||
|
sort.SliceStable(*files, func(i, j int) bool {
|
||||||
|
return (*files)[i].ModTime.After((*files)[j].ModTime)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
err = json.NewEncoder(w).Encode(files)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteRequest = DirectoryEntry
|
||||||
|
|
||||||
|
func DeleteFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req := new(DeleteRequest)
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := utils.ShaSumString(req.Path)
|
||||||
|
if sum != req.SHASum {
|
||||||
|
http.Error(w, "shasum mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "shasum mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
http.Error(w, "inexistent path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := hex.DecodeString(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedStr := string(decoded)
|
||||||
|
|
||||||
|
root := config.Instance().GetConfig().DownloadPath
|
||||||
|
|
||||||
|
// TODO: further path / file validations
|
||||||
|
if strings.Contains(filepath.Dir(decodedStr), root) {
|
||||||
|
// ctx.Response().Header.Set(
|
||||||
|
// "Content-Disposition",
|
||||||
|
// "inline; filename="+filepath.Base(decodedStr),
|
||||||
|
// )
|
||||||
|
|
||||||
|
http.ServeFile(w, r, decodedStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
65
server/handlers/login.go
Normal file
65
server/handlers/login.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req := new(LoginRequest)
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Instance().GetConfig().RPCSecret != req.Secret {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour * 24 * 30)
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"expiresAt": expiresAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: TOKEN_COOKIE_NAME,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
Expires: expiresAt, // 30 days
|
||||||
|
Value: tokenString,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: TOKEN_COOKIE_NAME,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
Expires: time.Now(),
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
}
|
||||||
@@ -66,8 +66,8 @@ type AbortRequest struct {
|
|||||||
// struct representing the intent to start a download
|
// struct representing the intent to start a download
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
Id string
|
Id string
|
||||||
URL string
|
URL string `json:"url"`
|
||||||
Path string
|
Path string `json:"path"`
|
||||||
Rename string
|
Rename string `json:"rename"`
|
||||||
Params []string
|
Params []string `json:"params"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,12 +125,18 @@ func (m *MemoryDB) Restore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, proc := range session.Processes {
|
for _, proc := range session.Processes {
|
||||||
m.table.Store(proc.Id, &Process{
|
restored := &Process{
|
||||||
Id: proc.Id,
|
Id: proc.Id,
|
||||||
Url: proc.Info.URL,
|
Url: proc.Info.URL,
|
||||||
Info: proc.Info,
|
Info: proc.Info,
|
||||||
Progress: proc.Progress,
|
Progress: proc.Progress,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
m.table.Store(proc.Id, restored)
|
||||||
|
|
||||||
|
if restored.Progress.Percentage != "-1" {
|
||||||
|
go restored.Start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
|
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/marcopeocchi/fazzoletti/slices"
|
"github.com/marcopeocchi/fazzoletti/slices"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,8 +137,11 @@ func (p *Process) Start() {
|
|||||||
Speed: stdout.Speed,
|
Speed: stdout.Speed,
|
||||||
ETA: stdout.Eta,
|
ETA: stdout.Eta,
|
||||||
}
|
}
|
||||||
shortId := strings.Split(p.Id, "-")[0]
|
log.Println(
|
||||||
log.Printf("[%s] %s %s\n", shortId, p.Url, p.Progress.Percentage)
|
cli.BgGreen, "DL", cli.Reset,
|
||||||
|
cli.BgBlue, p.getShortId(), cli.Reset,
|
||||||
|
p.Url, stdout.Percentage,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -156,6 +160,14 @@ func (p *Process) Complete() {
|
|||||||
Speed: 0,
|
Speed: 0,
|
||||||
ETA: 0,
|
ETA: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shortId := p.getShortId()
|
||||||
|
|
||||||
|
log.Println(
|
||||||
|
cli.BgMagenta, "FINISH", cli.Reset,
|
||||||
|
cli.BgBlue, shortId, cli.Reset,
|
||||||
|
p.Url,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill a process and remove it from the memory
|
// Kill a process and remove it from the memory
|
||||||
@@ -202,6 +214,12 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
|||||||
return DownloadFormats{}, err
|
return DownloadFormats{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Println(
|
||||||
|
cli.BgRed, "Metadata", cli.Reset,
|
||||||
|
cli.BgBlue, p.getShortId(), cli.Reset,
|
||||||
|
p.Url,
|
||||||
|
)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
decodingError = json.NewDecoder(stdout).Decode(&info)
|
decodingError = json.NewDecoder(stdout).Decode(&info)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
@@ -248,6 +266,12 @@ func (p *Process) SetMetadata() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Println(
|
||||||
|
cli.BgRed, "Metadata", cli.Reset,
|
||||||
|
cli.BgBlue, p.getShortId(), cli.Reset,
|
||||||
|
p.Url,
|
||||||
|
)
|
||||||
|
|
||||||
err = json.NewDecoder(stdout).Decode(&info)
|
err = json.NewDecoder(stdout).Decode(&info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -260,3 +284,7 @@ func (p *Process) SetMetadata() error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Process) getShortId() string {
|
||||||
|
return strings.Split(p.Id, "-")[0]
|
||||||
|
}
|
||||||
|
|||||||
25
server/rest/container.go
Normal file
25
server/rest/container.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
|
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Handler {
|
||||||
|
var (
|
||||||
|
service = ProvideService(db, mq)
|
||||||
|
handler = ProvideHandler(service)
|
||||||
|
)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
|
||||||
|
h := Container(db, mq)
|
||||||
|
|
||||||
|
return func(r chi.Router) {
|
||||||
|
r.Use(middlewares.Authenticated)
|
||||||
|
r.Post("/exec", h.Exec())
|
||||||
|
r.Get("/running", h.Running())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,212 +1,56 @@
|
|||||||
package rest
|
package rest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
type Handler struct {
|
||||||
TOKEN_COOKIE_NAME = "jwt"
|
service *Service
|
||||||
)
|
|
||||||
|
|
||||||
type DirectoryEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
SHASum string `json:"shaSum"`
|
|
||||||
ModTime time.Time `json:"modTime"`
|
|
||||||
IsVideo bool `json:"isVideo"`
|
|
||||||
IsDirectory bool `json:"isDirectory"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func walkDir(root string) (*[]DirectoryEntry, error) {
|
func (h *Handler) Exec() http.HandlerFunc {
|
||||||
files := []DirectoryEntry{}
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
dirs, err := os.ReadDir(root)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range dirs {
|
req := internal.DownloadRequest{}
|
||||||
if !utils.IsValidEntry(d) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(root, d.Name())
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
|
||||||
info, err := d.Info()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, DirectoryEntry{
|
|
||||||
Path: path,
|
|
||||||
Name: d.Name(),
|
|
||||||
Size: info.Size(),
|
|
||||||
SHASum: utils.ShaSumString(path),
|
|
||||||
IsVideo: utils.IsVideo(d),
|
|
||||||
IsDirectory: d.IsDir(),
|
|
||||||
ModTime: info.ModTime(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return &files, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListRequest struct {
|
|
||||||
SubDir string `json:"subdir"`
|
|
||||||
OrderBy string `json:"orderBy"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
|
|
||||||
root := config.Instance().GetConfig().DownloadPath
|
|
||||||
req := new(ListRequest)
|
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := walkDir(filepath.Join(root, req.SubDir))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.OrderBy == "modtime" {
|
|
||||||
sort.SliceStable(*files, func(i, j int) bool {
|
|
||||||
return (*files)[i].ModTime.After((*files)[j].ModTime)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
err = json.NewEncoder(w).Encode(files)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteRequest = DirectoryEntry
|
id, err := h.service.Exec(req)
|
||||||
|
|
||||||
func DeleteFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
req := new(DeleteRequest)
|
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sum := utils.ShaSumString(req.Path)
|
|
||||||
if sum != req.SHASum {
|
|
||||||
http.Error(w, "shasum mismatch", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Remove(req.Path)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "shasum mismatch", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode("ok")
|
|
||||||
}
|
|
||||||
|
|
||||||
func SendFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if path == "" {
|
|
||||||
http.Error(w, "inexistent path", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, err := hex.DecodeString(path)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
decodedStr := string(decoded)
|
|
||||||
|
|
||||||
root := config.Instance().GetConfig().DownloadPath
|
|
||||||
|
|
||||||
// TODO: further path / file validations
|
|
||||||
if strings.Contains(filepath.Dir(decodedStr), root) {
|
|
||||||
// ctx.Response().Header.Set(
|
|
||||||
// "Content-Disposition",
|
|
||||||
// "inline; filename="+filepath.Base(decodedStr),
|
|
||||||
// )
|
|
||||||
|
|
||||||
http.ServeFile(w, r, decodedStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginRequest struct {
|
|
||||||
Secret string `json:"secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
req := new(LoginRequest)
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Instance().GetConfig().RPCSecret != req.Secret {
|
err = json.NewEncoder(w).Encode(id)
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
if err != nil {
|
||||||
return
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Hour * 24 * 30)
|
func (h *Handler) Running() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
w.Header().Set("Content-Type", "application/json")
|
||||||
"expiresAt": expiresAt,
|
|
||||||
})
|
|
||||||
|
|
||||||
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
res, err := h.service.Running(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie := &http.Cookie{
|
err = json.NewEncoder(w).Encode(res)
|
||||||
Name: TOKEN_COOKIE_NAME,
|
if err != nil {
|
||||||
HttpOnly: true,
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
Secure: false,
|
|
||||||
Expires: expiresAt, // 30 days
|
|
||||||
Value: tokenString,
|
|
||||||
Path: "/",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, cookie)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cookie := &http.Cookie{
|
|
||||||
Name: TOKEN_COOKIE_NAME,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: false,
|
|
||||||
Expires: time.Now(),
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(w, cookie)
|
|
||||||
}
|
}
|
||||||
|
|||||||
34
server/rest/provider.go
Normal file
34
server/rest/provider.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
service *Service
|
||||||
|
handler *Handler
|
||||||
|
|
||||||
|
serviceOnce sync.Once
|
||||||
|
handlerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProvideService(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
|
||||||
|
serviceOnce.Do(func() {
|
||||||
|
service = &Service{
|
||||||
|
db: db,
|
||||||
|
mq: mq,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProvideHandler(svc *Service) *Handler {
|
||||||
|
handlerOnce.Do(func() {
|
||||||
|
handler = &Handler{
|
||||||
|
service: svc,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return handler
|
||||||
|
}
|
||||||
38
server/rest/service.go
Normal file
38
server/rest/service.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *internal.MemoryDB
|
||||||
|
mq *internal.MessageQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
|
||||||
|
p := &internal.Process{
|
||||||
|
Url: req.URL,
|
||||||
|
Params: req.Params,
|
||||||
|
Output: internal.DownloadOutput{
|
||||||
|
Path: req.Path,
|
||||||
|
Filename: req.Rename,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
id := s.db.Set(p)
|
||||||
|
s.mq.Publish(p)
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, errors.New("context cancelled")
|
||||||
|
default:
|
||||||
|
return s.db.All(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
24
server/rpc/container.go
Normal file
24
server/rpc/container.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
|
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dependency injection container.
|
||||||
|
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
mq: mq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC service must be registered before applying this router!
|
||||||
|
func ApplyRouter() func(chi.Router) {
|
||||||
|
return func(r chi.Router) {
|
||||||
|
r.Use(middlewares.Authenticated)
|
||||||
|
r.Get("/ws", WebSocket)
|
||||||
|
r.Post("/http", Post)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,14 +24,6 @@ type Args struct {
|
|||||||
Params []string
|
Params []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dependency injection container.
|
|
||||||
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
|
|
||||||
return &Service{
|
|
||||||
db: db,
|
|
||||||
mq: mq,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec spawns a Process.
|
// Exec spawns a Process.
|
||||||
// The result of the execution is the newly spawned process Id.
|
// The result of the execution is the newly spawned process Id.
|
||||||
func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
|
func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||||
@@ -68,23 +69,22 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
// Archive routes
|
// Archive routes
|
||||||
r.Route("/archive", func(r chi.Router) {
|
r.Route("/archive", func(r chi.Router) {
|
||||||
r.Use(middlewares.Authenticated)
|
r.Use(middlewares.Authenticated)
|
||||||
r.Post("/downloaded", rest.ListDownloaded)
|
r.Post("/downloaded", handlers.ListDownloaded)
|
||||||
r.Post("/delete", rest.DeleteFile)
|
r.Post("/delete", handlers.DeleteFile)
|
||||||
r.Get("/d/{id}", rest.SendFile)
|
r.Get("/d/{id}", handlers.SendFile)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Authentication routes
|
// Authentication routes
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Post("/login", rest.Login)
|
r.Post("/login", handlers.Login)
|
||||||
r.Get("/logout", rest.Logout)
|
r.Get("/logout", handlers.Logout)
|
||||||
})
|
})
|
||||||
|
|
||||||
// RPC handlers
|
// RPC handlers
|
||||||
r.Route("/rpc", func(r chi.Router) {
|
r.Route("/rpc", ytdlpRPC.ApplyRouter())
|
||||||
r.Use(middlewares.Authenticated)
|
|
||||||
r.Get("/ws", ytdlpRPC.WebSocket)
|
// REST API handlers
|
||||||
r.Post("/http", ytdlpRPC.Post)
|
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq))
|
||||||
})
|
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", c.port),
|
Addr: fmt.Sprintf(":%d", c.port),
|
||||||
|
|||||||
Reference in New Issue
Block a user