code refactoring

This commit is contained in:
2023-07-31 16:28:58 +02:00
parent b5731759b0
commit c0a424410e
18 changed files with 222 additions and 251 deletions

View File

@@ -5,7 +5,6 @@ import DownloadIcon from '@mui/icons-material/Download'
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 CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider'
@@ -21,20 +20,20 @@ import { useMemo, useState } from 'react'
import { Link, Outlet } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings'
import { statusState } from './atoms/status'
import { connectedState } from './atoms/status'
import AppBar from './components/AppBar'
import Drawer from './components/Drawer'
import FreeSpaceIndicator from './components/FreeSpaceIndicator'
import Logout from './components/Logout'
import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler'
import Toaster from './providers/ToasterProvider'
import { formatGiB } from './utils'
import SocketSubscriber from './components/SocketSubscriber'
export default function Layout() {
const [open, setOpen] = useState(false)
const settings = useRecoilValue(settingsState)
const status = useRecoilValue(statusState)
const isConnected = useRecoilValue(connectedState)
const mode = settings.theme
const theme = useMemo(() =>
@@ -48,9 +47,7 @@ export default function Layout() {
}), [settings.theme]
)
const toggleDrawer = () => {
setOpen(state => !state)
}
const toggleDrawer = () => setOpen(state => !state)
return (
<ThemeProvider theme={theme}>
@@ -80,20 +77,7 @@ export default function Layout() {
>
yt-dlp WebUI
</Typography>
{
status.freeSpace ?
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<Storage />
<span>
&nbsp;{formatGiB(status.freeSpace)}&nbsp;
</span>
</div>
: null
}
<FreeSpaceIndicator />
<div style={{
display: 'flex',
alignItems: 'center',
@@ -101,7 +85,7 @@ export default function Layout() {
}}>
<SettingsEthernet />
<span>
&nbsp;{status.connected ? settings.serverAddr : 'not connected'}
&nbsp;{isConnected ? settings.serverAddr : 'not connected'}
</span>
</div>
</Toolbar>

View File

@@ -1,10 +1,10 @@
import { atom, selector } from 'recoil'
import { rpcClientState } from './rpc'
type StatusState = {
connected: boolean,
updated: boolean,
downloading: boolean,
freeSpace: number,
}
@@ -23,17 +23,18 @@ export const isDownloadingState = atom({
default: false
})
export const freeSpaceBytesState = atom({
key: 'freeSpaceBytesState',
default: 0
})
// export const freeSpaceBytesState = selector({
// key: 'freeSpaceBytesState',
// get: async ({ get }) => {
// const res = await get(rpcClientState).freeSpace()
// return res.result
// }
// })
export const statusState = selector<StatusState>({
key: 'statusState',
get: ({ get }) => ({
connected: get(connectedState),
updated: get(updatedBinaryState),
downloading: get(isDownloadingState),
freeSpace: get(freeSpaceBytesState),
})
export const availableDownloadPathsState = selector({
key: 'availableDownloadPathsState',
get: async ({ get }) => {
const res = await get(rpcClientState).directoryTree()
return res.result
}
})

6
frontend/src/atoms/ui.ts Normal file
View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil'
export const loadingAtom = atom({
key: 'loadingAtom',
default: false
})

View File

@@ -26,15 +26,14 @@ import { TransitionProps } from '@mui/material/transitions'
import { Buffer } from 'buffer'
import {
forwardRef,
useEffect,
useMemo,
useRef,
useState,
useTransition
} from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings'
import { connectedState } from '../atoms/status'
import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
@@ -64,7 +63,8 @@ export default function DownloadDialog({
}: Props) {
// recoil state
const settings = useRecoilValue(settingsState)
const [isConnected] = useRecoilState(connectedState)
const isConnected = useRecoilValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
@@ -74,7 +74,6 @@ export default function DownloadDialog({
const [customArgs, setCustomArgs] = useState('')
const [downloadPath, setDownloadPath] = useState(0)
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
const [fileNameOverride, setFilenameOverride] = useState('')
@@ -96,19 +95,6 @@ export default function DownloadDialog({
const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null)
// effects
useEffect(() => {
client.directoryTree()
.then(data => {
setAvailableDownloadPaths(data.result)
})
}, [])
useEffect(() => {
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
}, [])
// transitions
const [isPending, startTransition] = useTransition()

View File

@@ -1,71 +1,31 @@
import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { DownloadsCardView } from './DownloadsCardView'
import { DownloadsListView } from './DownloadsListView'
import { useEffect } from 'react'
import { connectedState, isDownloadingState } from '../atoms/status'
import { datetimeCompareFunc, isRPCResponse } from '../utils'
import { RPCResponse, RPCResult } from '../types'
import { loadingAtom } from '../atoms/ui'
import DownloadsCardView from './DownloadsCardView'
import DownloadsListView from './DownloadsListView'
const Downloads: React.FC = () => {
const [active, setActive] = useRecoilState(activeDownloadsState)
const isConnected = useRecoilValue(connectedState)
const listView = useRecoilValue(listViewState)
const active = useRecoilValue(activeDownloadsState)
const { client, socket$ } = useRPC()
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
useEffect(() => {
if (!isConnected) { return }
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
if (!isRPCResponse(event)) { return }
if (!Array.isArray(event.result)) { return }
setActive(
(event.result || [])
.filter(f => !!f.info.url)
.sort((a, b) => datetimeCompareFunc(
b.info.created_at,
a.info.created_at,
))
)
})
return () => sub.unsubscribe()
}, [socket$, isConnected])
const [, setIsDownloading] = useRecoilState(isDownloadingState)
const [, setIsLoading] = useRecoilState(loadingAtom)
useEffect(() => {
if (active) {
setIsDownloading(true)
setIsLoading(true)
}
}, [active?.length])
if (listView) {
return (
<DownloadsListView
downloads={active ?? []}
onStop={abort}
/>
<DownloadsListView />
)
}
return (
<DownloadsCardView
downloads={active ?? []}
onStop={abort}
/>
<DownloadsCardView />
)
}

View File

@@ -1,19 +1,21 @@
import { Grid } from "@mui/material"
import { Fragment } from "react"
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from "../hooks/toast"
import { useI18n } from '../hooks/useI18n'
import type { RPCResult } from "../types"
import { useRPC } from '../hooks/useRPC'
import { StackableResult } from "./StackableResult"
type Props = {
downloads: RPCResult[]
onStop: (id: string) => void
}
const DownloadsCardView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? []
export function DownloadsCardView({ downloads, onStop }: Props) {
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const abort = (id: string) => client.kill(id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
{
@@ -25,7 +27,7 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
title={download.info.title}
thumbnail={download.info.thumbnail}
percentage={download.progress.percentage}
onStop={() => onStop(download.id)}
onStop={() => abort(download.id)}
onCopy={() => pushMessage(i18n.t('clipboardAction'))}
resolution={download.info.resolution ?? ''}
speed={download.progress.speed}
@@ -38,4 +40,6 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
}
</Grid>
)
}
}
export default DownloadsCardView

View File

@@ -11,15 +11,19 @@ import {
TableRow,
Typography
} from "@mui/material"
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { useRPC } from '../hooks/useRPC'
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
import type { RPCResult } from "../types"
type Props = {
downloads: RPCResult[]
onStop: (id: string) => void
}
export const DownloadsListView: React.FC<Props> = ({ downloads, onStop }) => {
const DownloadsListView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) ?? []
const { client } = useRPC()
const abort = (id: string) => client.kill(id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}>
@@ -70,7 +74,7 @@ export const DownloadsListView: React.FC<Props> = ({ downloads, onStop }) => {
<Button
variant="contained"
size="small"
onClick={() => onStop(download.id)}
onClick={() => abort(download.id)}
>
{download.progress.percentage === '-1' ? 'Remove' : 'Stop'}
</Button>
@@ -84,4 +88,6 @@ export const DownloadsListView: React.FC<Props> = ({ downloads, onStop }) => {
</Grid>
</Grid>
)
}
}
export default DownloadsListView

View File

@@ -0,0 +1,29 @@
import StorageIcon from '@mui/icons-material/Storage'
import { useEffect, useState } from 'react'
import { formatGiB } from '../utils'
import { useRPC } from '../hooks/useRPC'
const FreeSpaceIndicator = () => {
const [freeSpace, setFreeSpace] = useState(0)
const { client } = useRPC()
useEffect(() => {
client.freeSpace().then(r => setFreeSpace(r.result))
}, [client])
return (
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<StorageIcon />
<span>
&nbsp;{formatGiB(freeSpace)}&nbsp;
</span>
</div>
)
}
export default FreeSpaceIndicator

View File

@@ -0,0 +1,31 @@
import { useState } from 'react'
import { useRecoilState } from 'recoil'
import { loadingAtom } from '../atoms/ui'
import DownloadDialog from './DownloadDialog'
import HomeSpeedDial from './HomeSpeedDial'
const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom)
const [openDialog, setOpenDialog] = useState(false)
return (
<>
<HomeSpeedDial
onOpen={() => setOpenDialog(true)}
/>
<DownloadDialog
open={openDialog}
onClose={() => {
setOpenDialog(false)
setIsLoading(true)
}}
onDownloadStart={() => {
setOpenDialog(false)
setIsLoading(true)
}}
/>
</>
)
}
export default HomeActions

View File

@@ -0,0 +1,18 @@
import { Backdrop, CircularProgress } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui'
const LoadingBackdrop: React.FC = () => {
const isLoading = useRecoilValue(loadingAtom)
return (
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!isLoading}
>
<CircularProgress color="primary" />
</Backdrop>
)
}
export default LoadingBackdrop

View File

@@ -1,13 +1,15 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import LogoutIcon from '@mui/icons-material/Logout'
import { getHttpEndpoint } from '../utils'
import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
export default function Logout() {
const navigate = useNavigate()
const url = useRecoilValue(serverURL)
const logout = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/logout`)
const res = await fetch(`${url}/auth/logout`)
if (res.ok) {
navigate('/login')
}

View File

@@ -1,42 +1,62 @@
import { useEffect } from 'react'
import { useMemo } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { connectedState } from '../atoms/status'
import { useRPC } from '../hooks/useRPC'
import { useToast } from '../hooks/toast'
import { interval, share, take } from 'rxjs'
import { activeDownloadsState } from '../atoms/downloads'
import { serverAddressAndPortState } from '../atoms/settings'
import { connectedState } from '../atoms/status'
import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { datetimeCompareFunc, isRPCResponse } from '../utils'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = ({ children }) => {
const [isConnected, setIsConnected] = useRecoilState(connectedState)
const [, setIsConnected] = useRecoilState(connectedState)
const [, setActive] = useRecoilState(activeDownloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
const { i18n } = useI18n()
const { socket$ } = useRPC()
const { client } = useRPC()
const { pushMessage } = useToast()
useEffect(() => {
if (isConnected) { return }
const sharedSocket$ = useMemo(() => client.socket$.pipe(share()), [])
const socketOnce$ = useMemo(() => sharedSocket$.pipe(take(1)), [])
const sub = socket$.subscribe({
next: () => {
setIsConnected(true)
pushMessage(
`Connected to (${serverAddressAndPort})`,
"success"
)
},
error: (e) => {
console.error(e)
pushMessage(
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
"error"
)
}
})
return () => sub.unsubscribe()
}, [isConnected])
useSubscription(socketOnce$, () => {
setIsConnected(true)
pushMessage(
`${i18n.t('toastConnected')} (${serverAddressAndPort})`,
"success"
)
})
useSubscription(sharedSocket$,
(event) => {
if (!isRPCResponse(event)) { return }
if (!Array.isArray(event.result)) { return }
setActive(
(event.result || [])
.filter(f => !!f.info.url)
.sort((a, b) => datetimeCompareFunc(
b.info.created_at,
a.info.created_at,
))
)
},
(err) => {
console.error(err)
pushMessage(
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
"error"
)
}
)
useSubscription(interval(1000), () => client.running())
return (
<>{children}</>

View File

@@ -12,7 +12,6 @@ import {
Stack,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
type Props = {
@@ -40,15 +39,11 @@ export function StackableResult({
onStop,
onCopy,
}: Props) {
const [isCompleted, setIsCompleted] = useState(false)
const isCompleted = () => percentage === '-1'
useEffect(() => {
if (percentage === '-1') {
setIsCompleted(true)
}
}, [percentage])
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
const percentageToNumber = () => isCompleted()
? 100
: Number(percentage.replace('%', ''))
const guessResolution = (xByY: string): any => {
if (!xByY) return null
@@ -82,12 +77,12 @@ export function StackableResult({
}
<Stack direction="row" spacing={1} py={2}>
<Chip
label={isCompleted ? 'Completed' : mapProcessStatus(status)}
label={isCompleted() ? 'Completed' : mapProcessStatus(status)}
color="primary"
size="small"
/>
<Typography>{!isCompleted ? percentage : ''}</Typography>
<Typography> {!isCompleted ? formatSpeedMiB(speed) : ''}</Typography>
<Typography>{!isCompleted() ? percentage : ''}</Typography>
<Typography> {!isCompleted() ? formatSpeedMiB(speed) : ''}</Typography>
<Typography>{roundMiB(size ?? 0)}</Typography>
{guessResolution(resolution)}
</Stack>
@@ -95,7 +90,7 @@ export function StackableResult({
<LinearProgress
variant="determinate"
value={percentageToNumber()}
color={isCompleted ? "secondary" : "primary"}
color={isCompleted() ? "secondary" : "primary"}
/> :
null
}
@@ -108,7 +103,7 @@ export function StackableResult({
color="primary"
onClick={onStop}
>
{isCompleted ? "Clear" : "Stop"}
{isCompleted() ? "Clear" : "Stop"}
</Button>
</CardActions>
</Card>

View File

@@ -5,7 +5,6 @@ export const useRPC = () => {
const client = useRecoilValue(rpcClientState)
return {
client,
socket$: client.socket$
client
}
}

View File

@@ -1,11 +1,12 @@
import type { DLMetadata, RPCRequest, RPCResponse } from '../types'
import { Observable, share } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
export class RPCClient {
private seq: number
private httpEndpoint: string
private _socket$: WebSocketSubject<any>
private readonly _socket$: WebSocketSubject<any>
constructor(httpEndpoint: string, webSocketEndpoint: string) {
this.seq = 0
@@ -13,8 +14,8 @@ export class RPCClient {
this._socket$ = webSocket<any>(webSocketEndpoint)
}
public get socket$() {
return this._socket$
public get socket$(): Observable<RPCResponse<RPCResult[]>> {
return this._socket$.asObservable()
}
private incrementSeq() {
@@ -52,7 +53,7 @@ export class RPCClient {
return
}
if (playlist) {
return this.send({
return this.sendHTTP({
method: 'Service.ExecPlaylist',
params: [{
URL: url,
@@ -61,7 +62,7 @@ export class RPCClient {
}]
})
}
this.send({
this.sendHTTP({
method: 'Service.Exec',
params: [{
URL: url.split("?list").at(0)!,
@@ -91,14 +92,14 @@ export class RPCClient {
}
public kill(id: string) {
this.send({
this.sendHTTP({
method: 'Service.Kill',
params: [id],
})
}
public killAll() {
this.send({
this.sendHTTP({
method: 'Service.KillAll',
params: [],
})

View File

@@ -75,19 +75,6 @@ export function toFormatArgs(codes: string[]): string {
return ''
}
export function getWebSocketEndpoint() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws`
}
export function getHttpRPCEndpoint() {
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/http`
}
export function getHttpEndpoint() {
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}`
}
export function formatGiB(bytes: number) {
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
}

View File

@@ -1,79 +1,18 @@
import {
Backdrop,
CircularProgress,
Container
} from '@mui/material'
import { useEffect, useState } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { serverAddressAndPortState } from '../atoms/settings'
import { connectedState, freeSpaceBytesState, isDownloadingState } from '../atoms/status'
import DownloadDialog from '../components/DownloadDialog'
import Downloads from '../components/Downloads'
import HomeSpeedDial from '../components/HomeSpeedDial'
import HomeActions from '../components/HomeActions'
import LoadingBackdrop from '../components/LoadingBackdrop'
import Splash from '../components/Splash'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
export default function Home() {
const isDownloading = useRecoilValue(isDownloadingState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
const [, setFreeSpace] = useRecoilState(freeSpaceBytesState)
const [isConnected, setIsDownloading] = useRecoilState(connectedState)
const [openDialog, setOpenDialog] = useState(false)
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
useEffect(() => {
if (isConnected) {
client.running()
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}
}, [isConnected])
useEffect(() => {
client
.freeSpace()
.then(bytes => setFreeSpace(bytes.result))
.catch(() => {
pushMessage(
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
"error"
)
setIsDownloading(false)
})
}, [])
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!isDownloading}
>
<CircularProgress color="primary" />
</Backdrop>
<LoadingBackdrop />
<Splash />
<Downloads />
<HomeSpeedDial
onOpen={() => setOpenDialog(true)}
/>
<DownloadDialog
open={openDialog}
onClose={() => {
setOpenDialog(false)
setIsDownloading(false)
}}
onDownloadStart={() => {
setOpenDialog(false)
setIsDownloading(false)
}}
/>
<HomeActions />
</Container>
)
}

View File

@@ -13,7 +13,8 @@ import {
} from '@mui/material'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { getHttpEndpoint } from '../utils'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings'
const LoginContainer = styled(Container)({
display: 'flex',
@@ -35,10 +36,12 @@ export default function Login() {
const [secret, setSecret] = useState('')
const [formHasError, setFormHasError] = useState(false)
const url = useRecoilValue(serverURL)
const navigate = useNavigate()
const login = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/login`, {
const res = await fetch(`${url}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'