Added better archive functionalty (backend side atm)
Code refactoring
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { ThemeProvider } from '@emotion/react'
|
import { ThemeProvider } from '@emotion/react'
|
||||||
import ArchiveIcon from '@mui/icons-material/Archive'
|
import ArchiveIcon from '@mui/icons-material/Archive'
|
||||||
|
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
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 LiveTvIcon from '@mui/icons-material/LiveTv'
|
import LiveTvIcon from '@mui/icons-material/LiveTv'
|
||||||
@@ -42,7 +43,7 @@ export default function Layout() {
|
|||||||
palette: {
|
palette: {
|
||||||
mode: settings.theme,
|
mode: settings.theme,
|
||||||
primary: {
|
primary: {
|
||||||
main: getAccentValue(settings.accent)
|
main: getAccentValue(settings.accent, settings.theme)
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: settings.theme === 'light' ? grey[50] : '#121212'
|
default: settings.theme === 'light' ? grey[50] : '#121212'
|
||||||
@@ -113,7 +114,7 @@ export default function Layout() {
|
|||||||
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={'/archive'} style={
|
{/* <Link to={'/archive'} style={
|
||||||
{
|
{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
@@ -125,6 +126,19 @@ export default function Layout() {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
</Link> */}
|
||||||
|
<Link to={'/filebrowser'} style={
|
||||||
|
{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<ListItemButton>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CloudDownloadIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||||
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={'/monitor'} style={
|
<Link to={'/monitor'} style={
|
||||||
{
|
{
|
||||||
|
|||||||
98
frontend/src/components/ArchiveCard.tsx
Normal file
98
frontend/src/components/ArchiveCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
|
||||||
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
IconButton,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { ArchiveEntry } from '../types'
|
||||||
|
import { base64URLEncode, ellipsis } from '../utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entry: ArchiveEntry
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onHardDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArchiveCard: React.FC<Props> = ({ entry, onDelete, onHardDelete }) => {
|
||||||
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
|
||||||
|
const viewFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardActionArea onClick={() => navigator.clipboard.writeText(entry.source)}>
|
||||||
|
{entry.thumbnail !== '' ?
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height={180}
|
||||||
|
image={entry.thumbnail}
|
||||||
|
/> :
|
||||||
|
<Skeleton variant="rectangular" height={180} />
|
||||||
|
}
|
||||||
|
<CardContent>
|
||||||
|
{entry.title !== '' ?
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{ellipsis(entry.title, 60)}
|
||||||
|
</Typography> :
|
||||||
|
<Skeleton />
|
||||||
|
}
|
||||||
|
{/* <code>
|
||||||
|
{JSON.stringify(JSON.parse(entry.metadata), null, 2)}
|
||||||
|
</code> */}
|
||||||
|
<p>{new Date(entry.created_at).toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
<CardActions>
|
||||||
|
<Tooltip title="Open in browser">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => viewFile(entry.path)}
|
||||||
|
>
|
||||||
|
<OpenInBrowserIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Download this file">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => downloadFile(entry.path)}
|
||||||
|
>
|
||||||
|
<SaveAltIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete from archive">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete from disk">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onHardDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<DeleteForeverIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveCard
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import EightK from '@mui/icons-material/EightK'
|
|
||||||
import FourK from '@mui/icons-material/FourK'
|
|
||||||
import Hd from '@mui/icons-material/Hd'
|
|
||||||
import Sd from '@mui/icons-material/Sd'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -10,16 +6,23 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardMedia,
|
CardMedia,
|
||||||
Chip,
|
Chip,
|
||||||
|
IconButton,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tooltip,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { RPCResult } from '../types'
|
import { RPCResult } from '../types'
|
||||||
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
|
||||||
import { useAtomValue } from 'jotai'
|
import ResolutionBadge from './ResolutionBadge'
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear'
|
||||||
|
import StopCircleIcon from '@mui/icons-material/StopCircle'
|
||||||
|
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
|
||||||
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
download: RPCResult
|
download: RPCResult
|
||||||
@@ -27,15 +30,6 @@ type Props = {
|
|||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
|
||||||
if (!resolution) return null
|
|
||||||
if (resolution.includes('4320')) return <EightK color="primary" />
|
|
||||||
if (resolution.includes('2160')) return <FourK color="primary" />
|
|
||||||
if (resolution.includes('1080')) return <Hd color="primary" />
|
|
||||||
if (resolution.includes('720')) return <Sd color="primary" />
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||||
const serverAddr = useAtomValue(serverURL)
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
|
||||||
@@ -53,12 +47,12 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
|
|
||||||
const viewFile = (path: string) => {
|
const viewFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = (path: string) => {
|
const downloadFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,37 +104,44 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
|||||||
<Typography>
|
<Typography>
|
||||||
{formatSize(download.info.filesize_approx ?? 0)}
|
{formatSize(download.info.filesize_approx ?? 0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Resolution resolution={download.info.resolution} />
|
<ResolutionBadge resolution={download.info.resolution} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
<CardActions>
|
<CardActions>
|
||||||
<Button
|
{isCompleted() ?
|
||||||
variant="contained"
|
<Tooltip title="Clear from the view">
|
||||||
size="small"
|
<IconButton
|
||||||
color="primary"
|
onClick={onStop}
|
||||||
onClick={onStop}
|
>
|
||||||
>
|
<ClearIcon />
|
||||||
{isCompleted() ? "Clear" : "Stop"}
|
</IconButton>
|
||||||
</Button>
|
</Tooltip>
|
||||||
|
:
|
||||||
|
<Tooltip title="Stop this download">
|
||||||
|
<IconButton
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
<StopCircleIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
{isCompleted() &&
|
{isCompleted() &&
|
||||||
<>
|
<>
|
||||||
<Button
|
<Tooltip title="Download this file">
|
||||||
variant="contained"
|
<IconButton
|
||||||
size="small"
|
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||||
color="primary"
|
>
|
||||||
onClick={() => downloadFile(download.output.savedFilePath)}
|
<SaveAltIcon />
|
||||||
>
|
</IconButton>
|
||||||
Download
|
</Tooltip>
|
||||||
</Button>
|
<Tooltip title="Open in a new tab">
|
||||||
<Button
|
<IconButton
|
||||||
variant="contained"
|
onClick={() => viewFile(download.output.savedFilePath)}
|
||||||
size="small"
|
>
|
||||||
color="primary"
|
<OpenInBrowserIcon />
|
||||||
onClick={() => viewFile(download.output.savedFilePath)}
|
</IconButton>
|
||||||
>
|
</Tooltip>
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
|||||||
disabled={url === ''}
|
disabled={url === ''}
|
||||||
onClick={() => settings.formatSelection
|
onClick={() => settings.formatSelection
|
||||||
? startTransition(() => sendUrlFormatSelection())
|
? startTransition(() => sendUrlFormatSelection())
|
||||||
: sendUrl()
|
: startTransition(async () => await sendUrl())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Grid } from '@mui/material'
|
import { Grid2 } from '@mui/material'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useTransition } from 'react'
|
||||||
import { activeDownloadsState } from '../atoms/downloads'
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { ProcessStatus, RPCResult } from '../types'
|
import { ProcessStatus, RPCResult } from '../types'
|
||||||
import DownloadCard from './DownloadCard'
|
import DownloadCard from './DownloadCard'
|
||||||
|
import LoadingBackdrop from './LoadingBackdrop'
|
||||||
|
|
||||||
const DownloadsGridView: React.FC = () => {
|
const DownloadsGridView: React.FC = () => {
|
||||||
const downloads = useAtomValue(activeDownloadsState)
|
const downloads = useAtomValue(activeDownloadsState)
|
||||||
@@ -14,24 +16,31 @@ const DownloadsGridView: React.FC = () => {
|
|||||||
const { client } = useRPC()
|
const { client } = useRPC()
|
||||||
const { pushMessage } = useToast()
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
const [isPending, startTransition] = useTransition()
|
||||||
? client.clear(r.id)
|
|
||||||
: client.kill(r.id)
|
const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||||
|
? await client.clear(r.id)
|
||||||
|
: await client.kill(r.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
<>
|
||||||
{
|
<LoadingBackdrop isLoading={isPending} />
|
||||||
downloads.map(download => (
|
<Grid2 container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
||||||
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
|
{
|
||||||
<DownloadCard
|
downloads.map(download => (
|
||||||
download={download}
|
<Grid2 size={{ xs: 4, sm: 8, md: 6, xl: 4 }} key={download.id}>
|
||||||
onStop={() => stop(download)}
|
<DownloadCard
|
||||||
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
download={download}
|
||||||
/>
|
onStop={() => startTransition(async () => {
|
||||||
</Grid>
|
await stop(download)
|
||||||
))
|
})}
|
||||||
}
|
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ const DownloadsTableView: React.FC = () => {
|
|||||||
|
|
||||||
const viewFile = (path: string) => {
|
const viewFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = (path: string) => {
|
const downloadFile = (path: string) => {
|
||||||
const encoded = base64URLEncode(path)
|
const encoded = base64URLEncode(path)
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||||
|
|||||||
45
frontend/src/components/EmptyArchive.tsx
Normal file
45
frontend/src/components/EmptyArchive.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import ArchiveIcon from '@mui/icons-material/Archive'
|
||||||
|
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||||
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
const FlexContainer = styled(Container)({
|
||||||
|
display: 'flex',
|
||||||
|
minWidth: '100%',
|
||||||
|
minHeight: '80vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column'
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = styled(Typography)({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingBottom: '0.5rem'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function EmptyArchive() {
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const activeDownloads = useAtomValue(activeDownloadsState)
|
||||||
|
|
||||||
|
if (activeDownloads.length !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexContainer>
|
||||||
|
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||||
|
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||||
|
<ArchiveIcon />
|
||||||
|
</SvgIcon>
|
||||||
|
</Title>
|
||||||
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
|
{/* {i18n.t('splashText')} */}
|
||||||
|
Empty Archive
|
||||||
|
</Title>
|
||||||
|
</FlexContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/components/ResolutionBadge.tsx
Normal file
15
frontend/src/components/ResolutionBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import EightK from '@mui/icons-material/EightK'
|
||||||
|
import FourK from '@mui/icons-material/FourK'
|
||||||
|
import Hd from '@mui/icons-material/Hd'
|
||||||
|
import Sd from '@mui/icons-material/Sd'
|
||||||
|
|
||||||
|
const ResolutionBadge: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
||||||
|
if (!resolution) return null
|
||||||
|
if (resolution.includes('4320')) return <EightK color="primary" />
|
||||||
|
if (resolution.includes('2160')) return <FourK color="primary" />
|
||||||
|
if (resolution.includes('1080')) return <Hd color="primary" />
|
||||||
|
if (resolution.includes('720')) return <Sd color="primary" />
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolutionBadge
|
||||||
@@ -201,7 +201,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: <Button
|
endAdornment: <Button
|
||||||
variant='contained'
|
variant='contained'
|
||||||
onClick={() => startTransition(() => { addTemplate() })}
|
onClick={() => startTransition(async () => await addTemplate())}
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -128,21 +128,21 @@ export class RPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public kill(id: string) {
|
public kill(id: string) {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.Kill',
|
method: 'Service.Kill',
|
||||||
params: [id],
|
params: [id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(id: string) {
|
public clear(id: string) {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.Clear',
|
method: 'Service.Clear',
|
||||||
params: [id],
|
params: [id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public killAll() {
|
public killAll() {
|
||||||
this.sendHTTP({
|
return this.sendHTTP({
|
||||||
method: 'Service.KillAll',
|
method: 'Service.KillAll',
|
||||||
params: [],
|
params: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const Login = lazy(() => import('./views/Login'))
|
|||||||
const Archive = lazy(() => import('./views/Archive'))
|
const Archive = lazy(() => import('./views/Archive'))
|
||||||
const Settings = lazy(() => import('./views/Settings'))
|
const Settings = lazy(() => import('./views/Settings'))
|
||||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||||
|
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||||
|
|
||||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||||
|
|
||||||
@@ -59,6 +60,19 @@ export const router = createHashRouter([
|
|||||||
</Suspense >
|
</Suspense >
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/filebrowser',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<Filebrowser />
|
||||||
|
</Suspense >
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<Suspense fallback={<CircularProgress />}>
|
||||||
|
<ErrorBoundary />
|
||||||
|
</Suspense >
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -123,3 +123,19 @@ export type RPCVersion = {
|
|||||||
rpcVersion: string
|
rpcVersion: string
|
||||||
ytdlpVersion: string
|
ytdlpVersion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ArchiveEntry = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
thumbnail: string
|
||||||
|
source: string
|
||||||
|
metadata: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
first: number
|
||||||
|
next: number
|
||||||
|
data: T
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { blue, red } from '@mui/material/colors'
|
import { blue, red } from '@mui/material/colors'
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
import { Accent } from './atoms/settings'
|
import { Accent, ThemeNarrowed } from './atoms/settings'
|
||||||
import type { RPCResponse } from "./types"
|
import type { RPCResponse } from "./types"
|
||||||
import { ProcessStatus } from './types'
|
import { ProcessStatus } from './types'
|
||||||
|
|
||||||
@@ -83,13 +83,13 @@ export const base64URLEncode = (s: string) => pipe(
|
|||||||
encodeURIComponent
|
encodeURIComponent
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getAccentValue = (accent: Accent) => {
|
export const getAccentValue = (accent: Accent, mode: ThemeNarrowed) => {
|
||||||
switch (accent) {
|
switch (accent) {
|
||||||
case 'default':
|
case 'default':
|
||||||
return blue[700]
|
return mode === 'light' ? blue[700] : blue[300]
|
||||||
case 'red':
|
case 'red':
|
||||||
return red[600]
|
return mode === 'light' ? red[600] : red[400]
|
||||||
default:
|
default:
|
||||||
return blue[700]
|
return mode === 'light' ? blue[700] : blue[300]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,363 +1,112 @@
|
|||||||
import {
|
import { Container, FormControl, Grid2, InputLabel, MenuItem, Pagination, Select } from '@mui/material'
|
||||||
Backdrop,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
Paper,
|
|
||||||
SpeedDial,
|
|
||||||
SpeedDialAction,
|
|
||||||
SpeedDialIcon,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
|
||||||
import FolderIcon from '@mui/icons-material/Folder'
|
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
|
||||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download'
|
|
||||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
|
||||||
import { pipe } from 'fp-ts/lib/function'
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
|
||||||
import { serverURL } from '../atoms/settings'
|
|
||||||
import { useObservable } from '../hooks/observable'
|
|
||||||
import { useToast } from '../hooks/toast'
|
|
||||||
import { useI18n } from '../hooks/useI18n'
|
|
||||||
import { ffetch } from '../lib/httpClient'
|
|
||||||
import { DirectoryEntry } from '../types'
|
|
||||||
import { base64URLEncode, formatSize } from '../utils'
|
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useEffect, useState, useTransition } from 'react'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import ArchiveCard from '../components/ArchiveCard'
|
||||||
|
import EmptyArchive from '../components/EmptyArchive'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { ArchiveEntry, PaginatedResponse } from '../types'
|
||||||
|
import LoadingBackdrop from '../components/LoadingBackdrop'
|
||||||
|
|
||||||
export default function Downloaded() {
|
const Archive: React.FC = () => {
|
||||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
const [isLoading, setLoading] = useState(true)
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [archiveEntries, setArchiveEntries] = useState<ArchiveEntry[]>()
|
||||||
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
|
||||||
|
|
||||||
const serverAddr = useAtomValue(serverURL)
|
const [currentCursor, setCurrentCursor] = useState(0)
|
||||||
const navigate = useNavigate()
|
const [cursor, setCursor] = useState({ first: 0, next: 0 })
|
||||||
|
const [pageSize, setPageSize] = useState(25)
|
||||||
const { i18n } = useI18n()
|
|
||||||
const { pushMessage } = useToast()
|
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
|
||||||
|
|
||||||
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
|
||||||
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const fetcher = () => pipe(
|
const serverAddr = useAtomValue(serverURL)
|
||||||
ffetch<DirectoryEntry[]>(
|
const { pushMessage } = useToast()
|
||||||
`${serverAddr}/archive/downloaded`,
|
|
||||||
{
|
const fetchArchived = (startCursor = 0) => pipe(
|
||||||
method: 'POST',
|
ffetch<PaginatedResponse<ArchiveEntry[]>>(`${serverAddr}/archive?id=${startCursor}&limit=${pageSize}`),
|
||||||
body: JSON.stringify({
|
|
||||||
subdir: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
),
|
|
||||||
matchW(
|
matchW(
|
||||||
(e) => {
|
(l) => pushMessage(l, 'error'),
|
||||||
pushMessage(e, 'error')
|
(r) => {
|
||||||
navigate('/login')
|
setArchiveEntries(r.data)
|
||||||
},
|
setCursor({ ...cursor, first: r.first, next: r.next })
|
||||||
(d) => files$.next(d ?? []),
|
}
|
||||||
)
|
)
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const fetcherSubfolder = (sub: string) => {
|
const softDelete = (id: string) => pipe(
|
||||||
const folders = sub.startsWith('/')
|
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/soft/${id}`, {
|
||||||
? sub.substring(1).split('/')
|
method: 'DELETE'
|
||||||
: sub.split('/')
|
|
||||||
|
|
||||||
const relpath = folders.length >= 2
|
|
||||||
? folders.slice(-(folders.length - 1)).join('/')
|
|
||||||
: folders.pop()
|
|
||||||
|
|
||||||
const _upperLevel = folders.slice(1, -1)
|
|
||||||
const upperLevel = _upperLevel.length === 2
|
|
||||||
? ['.', ..._upperLevel].join('/')
|
|
||||||
: _upperLevel.join('/')
|
|
||||||
|
|
||||||
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ subdir: relpath })
|
|
||||||
})
|
|
||||||
|
|
||||||
pipe(
|
|
||||||
task,
|
|
||||||
matchW(
|
|
||||||
(l) => pushMessage(l, 'error'),
|
|
||||||
(r) => files$.next(sub
|
|
||||||
? [{
|
|
||||||
isDirectory: true,
|
|
||||||
isVideo: false,
|
|
||||||
modTime: '',
|
|
||||||
name: '..',
|
|
||||||
path: upperLevel,
|
|
||||||
size: 0,
|
|
||||||
}, ...r.filter(f => f.name !== '')]
|
|
||||||
: r.filter(f => f.name !== '')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectable$ = useMemo(() => files$.pipe(
|
|
||||||
combineLatestWith(selected$),
|
|
||||||
map(([data, selected]) => data.map(x => ({
|
|
||||||
...x,
|
|
||||||
selected: selected.includes(x.name)
|
|
||||||
}))),
|
|
||||||
share()
|
|
||||||
), [])
|
|
||||||
|
|
||||||
const selectable = useObservable(selectable$, [])
|
|
||||||
|
|
||||||
const addSelected = (name: string) => {
|
|
||||||
selected$.value.includes(name)
|
|
||||||
? selected$.next(selected$.value.filter(val => val !== name))
|
|
||||||
: selected$.next([...selected$.value, name])
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteFile = (entry: DirectoryEntry) => pipe(
|
|
||||||
ffetch(`${serverAddr}/archive/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: entry.path,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
matchW(
|
matchW(
|
||||||
(l) => pushMessage(l, 'error'),
|
(l) => pushMessage(l, 'error'),
|
||||||
(_) => fetcher()
|
(_) => startTransition(async () => await fetchArchived())
|
||||||
)
|
)
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const deleteSelected = () => {
|
const hardDelete = (id: string) => pipe(
|
||||||
Promise.all(selectable
|
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/hard/${id}`, {
|
||||||
.filter(entry => entry.selected)
|
method: 'DELETE'
|
||||||
.map(deleteFile)
|
}),
|
||||||
).then(fetcher)
|
matchW(
|
||||||
}
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => startTransition(async () => await fetchArchived())
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const setPage = (page: number) => setCurrentCursor(pageSize * (page - 1))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetcher()
|
fetchArchived(currentCursor).then(() => setLoading(false))
|
||||||
}, [serverAddr])
|
}, [currentCursor])
|
||||||
|
|
||||||
const onFileClick = (path: string) => startTransition(() => {
|
|
||||||
const encoded = base64URLEncode(path)
|
|
||||||
|
|
||||||
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const downloadFile = (path: string) => startTransition(() => {
|
|
||||||
const encoded = base64URLEncode(path)
|
|
||||||
|
|
||||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onFolderClick = (path: string) => startTransition(() => {
|
|
||||||
fetcherSubfolder(path)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 8, minHeight: '80vh' }}>
|
||||||
maxWidth="xl"
|
<LoadingBackdrop isLoading={isPending || isLoading} />
|
||||||
sx={{ mt: 4, mb: 4, height: '100%' }}
|
{
|
||||||
onClick={() => setShowMenu(false)}
|
archiveEntries && archiveEntries.length !== 0 ?
|
||||||
>
|
<Grid2
|
||||||
<IconMenu
|
container
|
||||||
posX={menuPos.x}
|
spacing={{ xs: 2, md: 2 }}
|
||||||
posY={menuPos.y}
|
columns={{ xs: 4, sm: 8, md: 12, xl: 12 }}
|
||||||
hide={!showMenu}
|
pt={2}
|
||||||
onDownload={() => {
|
sx={{ minHeight: '77.5vh' }}
|
||||||
if (currentFile) {
|
|
||||||
downloadFile(currentFile?.path)
|
|
||||||
setCurrentFile(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
if (currentFile) {
|
|
||||||
deleteFile(currentFile)
|
|
||||||
setCurrentFile(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Backdrop
|
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
|
||||||
open={!(files$.observed) || isPending}
|
|
||||||
>
|
|
||||||
<CircularProgress color="primary" />
|
|
||||||
</Backdrop>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
onClick={() => setShowMenu(false)}
|
|
||||||
>
|
|
||||||
<Typography py={1} variant="h5" color="primary">
|
|
||||||
{i18n.t('archiveTitle')}
|
|
||||||
</Typography>
|
|
||||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
|
||||||
{selectable.length === 0 && 'No files found'}
|
|
||||||
{selectable.map((file, idx) => (
|
|
||||||
<ListItem
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setCurrentFile(file)
|
|
||||||
setMenuPos({ x: e.clientX, y: e.clientY })
|
|
||||||
setShowMenu(true)
|
|
||||||
}}
|
|
||||||
key={idx}
|
|
||||||
secondaryAction={
|
|
||||||
<div>
|
|
||||||
{!file.isDirectory && <Typography
|
|
||||||
variant="caption"
|
|
||||||
component="span"
|
|
||||||
>
|
|
||||||
{formatSize(file.size)}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
{!file.isDirectory && <>
|
|
||||||
<Checkbox
|
|
||||||
edge="end"
|
|
||||||
checked={file.selected}
|
|
||||||
onChange={() => addSelected(file.name)}
|
|
||||||
/>
|
|
||||||
</>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
disablePadding
|
|
||||||
>
|
|
||||||
<ListItemButton onClick={
|
|
||||||
() => file.isDirectory
|
|
||||||
? onFolderClick(file.path)
|
|
||||||
: onFileClick(file.path)
|
|
||||||
}>
|
|
||||||
<ListItemIcon>
|
|
||||||
{file.isDirectory
|
|
||||||
? <FolderIcon />
|
|
||||||
: file.isVideo
|
|
||||||
? <VideoFileIcon />
|
|
||||||
: <InsertDriveFileIcon />
|
|
||||||
}
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={file.name}
|
|
||||||
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
<SpeedDial
|
|
||||||
ariaLabel='archive actions'
|
|
||||||
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
|
||||||
icon={<SpeedDialIcon />}
|
|
||||||
>
|
|
||||||
<SpeedDialAction
|
|
||||||
icon={<DeleteForeverIcon />}
|
|
||||||
tooltipTitle={`Delete selected`}
|
|
||||||
tooltipOpen
|
|
||||||
onClick={() => {
|
|
||||||
if (selected$.value.length > 0) {
|
|
||||||
setOpenDialog(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SpeedDial>
|
|
||||||
<Dialog
|
|
||||||
open={openDialog}
|
|
||||||
onClose={() => setOpenDialog(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
Are you sure?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText id="alert-dialog-description">
|
|
||||||
You're deleting:
|
|
||||||
</DialogContentText>
|
|
||||||
<ul>
|
|
||||||
{selected$.value.map((entry, idx) => (
|
|
||||||
<li key={idx}>{entry}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setOpenDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
deleteSelected()
|
|
||||||
setOpenDialog(false)
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
>
|
>
|
||||||
Ok
|
{
|
||||||
</Button>
|
archiveEntries.map((entry) => (
|
||||||
</DialogActions>
|
<Grid2 size={{ xs: 4, sm: 8, md: 4, xl: 3 }} key={entry.id}>
|
||||||
</Dialog>
|
<ArchiveCard
|
||||||
|
entry={entry}
|
||||||
|
onDelete={() => startTransition(async () => await softDelete(entry.id))}
|
||||||
|
onHardDelete={() => startTransition(async () => await hardDelete(entry.id))}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Grid2>
|
||||||
|
: <EmptyArchive />
|
||||||
|
}
|
||||||
|
<Pagination
|
||||||
|
sx={{ mx: 'auto', pt: 2 }}
|
||||||
|
count={Math.floor(cursor.next / pageSize) + 1}
|
||||||
|
onChange={(_, v) => setPage(v)}
|
||||||
|
/>
|
||||||
|
<FormControl variant="standard" sx={{ m: 1, minWidth: 120 }}>
|
||||||
|
<InputLabel id="page-size-select-label">Page size</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="page-size-select-label"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||||
|
label="Page size"
|
||||||
|
>
|
||||||
|
<MenuItem value={25}>25</MenuItem>
|
||||||
|
<MenuItem value={50}>50</MenuItem>
|
||||||
|
<MenuItem value={100}>100</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconMenu: React.FC<{
|
export default Archive
|
||||||
posX: number
|
|
||||||
posY: number
|
|
||||||
hide: boolean
|
|
||||||
onDownload: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
|
||||||
return (
|
|
||||||
<Paper sx={{
|
|
||||||
width: 320,
|
|
||||||
maxWidth: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: posY,
|
|
||||||
left: posX,
|
|
||||||
display: hide ? 'none' : 'block',
|
|
||||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
|
||||||
}}>
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem onClick={onDownload}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DownloadIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>
|
|
||||||
Download
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={onDelete}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DeleteForeverIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>
|
|
||||||
Delete
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
360
frontend/src/views/Filebrowser.tsx
Normal file
360
frontend/src/views/Filebrowser.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import {
|
||||||
|
Backdrop,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Paper,
|
||||||
|
SpeedDial,
|
||||||
|
SpeedDialAction,
|
||||||
|
SpeedDialIcon,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder'
|
||||||
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||||
|
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||||
|
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||||
|
import { serverURL } from '../atoms/settings'
|
||||||
|
import { useObservable } from '../hooks/observable'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { useI18n } from '../hooks/useI18n'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
import { DirectoryEntry } from '../types'
|
||||||
|
import { base64URLEncode, formatSize } from '../utils'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
export default function Downloaded() {
|
||||||
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
||||||
|
|
||||||
|
const serverAddr = useAtomValue(serverURL)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
||||||
|
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const fetcher = () => pipe(
|
||||||
|
ffetch<DirectoryEntry[]>(
|
||||||
|
`${serverAddr}/filebrowser/downloaded`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
subdir: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
),
|
||||||
|
matchW(
|
||||||
|
(e) => {
|
||||||
|
pushMessage(e, 'error')
|
||||||
|
navigate('/login')
|
||||||
|
},
|
||||||
|
(d) => files$.next(d ?? []),
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const fetcherSubfolder = (sub: string) => {
|
||||||
|
const folders = sub.startsWith('/')
|
||||||
|
? sub.substring(1).split('/')
|
||||||
|
: sub.split('/')
|
||||||
|
|
||||||
|
const relpath = folders.length >= 2
|
||||||
|
? folders.slice(-(folders.length - 1)).join('/')
|
||||||
|
: folders.pop()
|
||||||
|
|
||||||
|
const _upperLevel = folders.slice(1, -1)
|
||||||
|
const upperLevel = _upperLevel.length === 2
|
||||||
|
? ['.', ..._upperLevel].join('/')
|
||||||
|
: _upperLevel.join('/')
|
||||||
|
|
||||||
|
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/filebrowser/downloaded`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ subdir: relpath })
|
||||||
|
})
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
task,
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(r) => files$.next(sub
|
||||||
|
? [{
|
||||||
|
isDirectory: true,
|
||||||
|
isVideo: false,
|
||||||
|
modTime: '',
|
||||||
|
name: '..',
|
||||||
|
path: upperLevel,
|
||||||
|
size: 0,
|
||||||
|
}, ...r.filter(f => f.name !== '')]
|
||||||
|
: r.filter(f => f.name !== '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable$ = useMemo(() => files$.pipe(
|
||||||
|
combineLatestWith(selected$),
|
||||||
|
map(([data, selected]) => data.map(x => ({
|
||||||
|
...x,
|
||||||
|
selected: selected.includes(x.name)
|
||||||
|
}))),
|
||||||
|
share()
|
||||||
|
), [])
|
||||||
|
|
||||||
|
const selectable = useObservable(selectable$, [])
|
||||||
|
|
||||||
|
const addSelected = (name: string) => {
|
||||||
|
selected$.value.includes(name)
|
||||||
|
? selected$.next(selected$.value.filter(val => val !== name))
|
||||||
|
: selected$.next([...selected$.value, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = (entry: DirectoryEntry) => pipe(
|
||||||
|
ffetch(`${serverAddr}/filebrowser/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: entry.path,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
matchW(
|
||||||
|
(l) => pushMessage(l, 'error'),
|
||||||
|
(_) => fetcher()
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
|
||||||
|
const deleteSelected = () => {
|
||||||
|
Promise.all(selectable
|
||||||
|
.filter(entry => entry.selected)
|
||||||
|
.map(deleteFile)
|
||||||
|
).then(fetcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetcher()
|
||||||
|
}, [serverAddr])
|
||||||
|
|
||||||
|
const onFileClick = (path: string) => startTransition(() => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadFile = (path: string) => startTransition(() => {
|
||||||
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFolderClick = (path: string) => startTransition(() => {
|
||||||
|
fetcherSubfolder(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="xl"
|
||||||
|
sx={{ mt: 4, mb: 4, minHeight: '100%' }}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<IconMenu
|
||||||
|
posX={menuPos.x}
|
||||||
|
posY={menuPos.y}
|
||||||
|
hide={!showMenu}
|
||||||
|
onDownload={() => {
|
||||||
|
if (currentFile) {
|
||||||
|
downloadFile(currentFile?.path)
|
||||||
|
setCurrentFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (currentFile) {
|
||||||
|
deleteFile(currentFile)
|
||||||
|
setCurrentFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Backdrop
|
||||||
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
|
open={!(files$.observed) || isPending}
|
||||||
|
>
|
||||||
|
<CircularProgress color="primary" />
|
||||||
|
</Backdrop>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
|
{selectable.length === 0 && 'No files found'}
|
||||||
|
{selectable.map((file, idx) => (
|
||||||
|
<ListItem
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCurrentFile(file)
|
||||||
|
setMenuPos({ x: e.clientX, y: e.clientY })
|
||||||
|
setShowMenu(true)
|
||||||
|
}}
|
||||||
|
key={idx}
|
||||||
|
secondaryAction={
|
||||||
|
<div>
|
||||||
|
{!file.isDirectory && <Typography
|
||||||
|
variant="caption"
|
||||||
|
component="span"
|
||||||
|
>
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
{!file.isDirectory && <>
|
||||||
|
<Checkbox
|
||||||
|
edge="end"
|
||||||
|
checked={file.selected}
|
||||||
|
onChange={() => addSelected(file.name)}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disablePadding
|
||||||
|
>
|
||||||
|
<ListItemButton onClick={
|
||||||
|
() => file.isDirectory
|
||||||
|
? onFolderClick(file.path)
|
||||||
|
: onFileClick(file.path)
|
||||||
|
}>
|
||||||
|
<ListItemIcon>
|
||||||
|
{file.isDirectory
|
||||||
|
? <FolderIcon />
|
||||||
|
: file.isVideo
|
||||||
|
? <VideoFileIcon />
|
||||||
|
: <InsertDriveFileIcon />
|
||||||
|
}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={file.name}
|
||||||
|
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel='archive actions'
|
||||||
|
sx={{ position: 'absolute', bottom: 64, right: 24 }}
|
||||||
|
icon={<SpeedDialIcon />}
|
||||||
|
>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<DeleteForeverIcon />}
|
||||||
|
tooltipTitle={`Delete selected`}
|
||||||
|
tooltipOpen
|
||||||
|
onClick={() => {
|
||||||
|
if (selected$.value.length > 0) {
|
||||||
|
setOpenDialog(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
<Dialog
|
||||||
|
open={openDialog}
|
||||||
|
onClose={() => setOpenDialog(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Are you sure?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="alert-dialog-description">
|
||||||
|
You're deleting:
|
||||||
|
</DialogContentText>
|
||||||
|
<ul>
|
||||||
|
{selected$.value.map((entry, idx) => (
|
||||||
|
<li key={idx}>{entry}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpenDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
deleteSelected()
|
||||||
|
setOpenDialog(false)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
Ok
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconMenu: React.FC<{
|
||||||
|
posX: number
|
||||||
|
posY: number
|
||||||
|
hide: boolean
|
||||||
|
onDownload: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
||||||
|
return (
|
||||||
|
<Paper sx={{
|
||||||
|
width: 320,
|
||||||
|
maxWidth: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: posY,
|
||||||
|
left: posX,
|
||||||
|
display: hide ? 'none' : 'block',
|
||||||
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
|
}}>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={onDownload}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DownloadIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
Download
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onDelete}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DeleteForeverIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
Delete
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
server/archive/archive.go
Normal file
18
server/archive/archive.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alias type
|
||||||
|
// TODO: remove after refactoring
|
||||||
|
type Service = domain.Service
|
||||||
|
type Entity = domain.ArchiveEntry
|
||||||
|
|
||||||
|
func ApplyRouter(db *sql.DB) func(chi.Router) {
|
||||||
|
handler, _ := Container(db)
|
||||||
|
return handler.ApplyRouter()
|
||||||
|
}
|
||||||
16
server/archive/container.go
Normal file
16
server/archive/container.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Container(db *sql.DB) (domain.RestHandler, domain.Service) {
|
||||||
|
var (
|
||||||
|
r = provideRepository(db)
|
||||||
|
s = provideService(r)
|
||||||
|
h = provideHandler(s)
|
||||||
|
)
|
||||||
|
return h, s
|
||||||
|
}
|
||||||
13
server/archive/data/models.go
Normal file
13
server/archive/data/models.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ArchiveEntry struct {
|
||||||
|
Id string
|
||||||
|
Title string
|
||||||
|
Path string
|
||||||
|
Thumbnail string
|
||||||
|
Source string
|
||||||
|
Metadata string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
51
server/archive/domain/archive.go
Normal file
51
server/archive/domain/archive.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveEntry struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResponse[T any] struct {
|
||||||
|
First int64 `json:"first"`
|
||||||
|
Next int64 `json:"next"`
|
||||||
|
Data T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
Archive(ctx context.Context, model *data.ArchiveEntry) error
|
||||||
|
SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error)
|
||||||
|
HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error)
|
||||||
|
List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error)
|
||||||
|
GetCursor(ctx context.Context, id string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
Archive(ctx context.Context, entity *ArchiveEntry) error
|
||||||
|
SoftDelete(ctx context.Context, id string) (*ArchiveEntry, error)
|
||||||
|
HardDelete(ctx context.Context, id string) (*ArchiveEntry, error)
|
||||||
|
List(ctx context.Context, startRowId int, limit int) (*PaginatedResponse[[]ArchiveEntry], error)
|
||||||
|
GetCursor(ctx context.Context, id string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RestHandler interface {
|
||||||
|
List() http.HandlerFunc
|
||||||
|
Archive() http.HandlerFunc
|
||||||
|
SoftDelete() http.HandlerFunc
|
||||||
|
HardDelete() http.HandlerFunc
|
||||||
|
GetCursor() http.HandlerFunc
|
||||||
|
ApplyRouter() func(chi.Router)
|
||||||
|
}
|
||||||
42
server/archive/provider.go
Normal file
42
server/archive/provider.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/repository"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/rest"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
repo domain.Repository
|
||||||
|
svc domain.Service
|
||||||
|
hand domain.RestHandler
|
||||||
|
|
||||||
|
repoOnce sync.Once
|
||||||
|
svcOnce sync.Once
|
||||||
|
handOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func provideRepository(db *sql.DB) domain.Repository {
|
||||||
|
repoOnce.Do(func() {
|
||||||
|
repo = repository.New(db)
|
||||||
|
})
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideService(r domain.Repository) domain.Service {
|
||||||
|
svcOnce.Do(func() {
|
||||||
|
svc = service.New(r)
|
||||||
|
})
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideHandler(s domain.Service) domain.RestHandler {
|
||||||
|
handOnce.Do(func() {
|
||||||
|
hand = rest.New(s)
|
||||||
|
})
|
||||||
|
return hand
|
||||||
|
}
|
||||||
156
server/archive/repository/repository.go
Normal file
156
server/archive/repository/repository.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) domain.Repository {
|
||||||
|
return &Repository{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Archive(ctx context.Context, entry *data.ArchiveEntry) error {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO archive (id, title, path, thumbnail, source, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
uuid.NewString(),
|
||||||
|
entry.Title,
|
||||||
|
entry.Path,
|
||||||
|
entry.Thumbnail,
|
||||||
|
entry.Source,
|
||||||
|
entry.Metadata,
|
||||||
|
entry.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SoftDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var model data.ArchiveEntry
|
||||||
|
|
||||||
|
row := tx.QueryRowContext(ctx, "SELECT * FROM archive WHERE id = ?", id)
|
||||||
|
|
||||||
|
if err := row.Scan(
|
||||||
|
&model.Id,
|
||||||
|
&model.Title,
|
||||||
|
&model.Path,
|
||||||
|
&model.Thumbnail,
|
||||||
|
&model.Source,
|
||||||
|
&model.Metadata,
|
||||||
|
&model.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM archive WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) HardDelete(ctx context.Context, id string) (*data.ArchiveEntry, error) {
|
||||||
|
entry, err := r.SoftDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(entry.Path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context, startRowId int, limit int) (*[]data.ArchiveEntry, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var entries []data.ArchiveEntry
|
||||||
|
|
||||||
|
// cursor based pagination
|
||||||
|
rows, err := conn.QueryContext(ctx, "SELECT rowid, * FROM archive WHERE rowid > ? LIMIT ?", startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var rowId int64
|
||||||
|
var entry data.ArchiveEntry
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&rowId,
|
||||||
|
&entry.Id,
|
||||||
|
&entry.Title,
|
||||||
|
&entry.Path,
|
||||||
|
&entry.Thumbnail,
|
||||||
|
&entry.Source,
|
||||||
|
&entry.Metadata,
|
||||||
|
&entry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return &entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||||
|
conn, err := r.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
row := conn.QueryRowContext(ctx, "SELECT rowid FROM archive WHERE id = ?", id)
|
||||||
|
|
||||||
|
var rowId int64
|
||||||
|
|
||||||
|
if err := row.Scan(&rowId); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowId, nil
|
||||||
|
}
|
||||||
162
server/archive/rest/handler.go
Normal file
162
server/archive/rest/handler.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
|
||||||
|
|
||||||
|
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service domain.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(service domain.Service) domain.RestHandler {
|
||||||
|
return &Handler{
|
||||||
|
service: service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements domain.RestHandler.
|
||||||
|
func (h *Handler) List() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var (
|
||||||
|
startRowIdParam = r.URL.Query().Get("id")
|
||||||
|
LimitParam = r.URL.Query().Get("limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
startRowId, err := strconv.Atoi(startRowIdParam)
|
||||||
|
if err != nil {
|
||||||
|
startRowId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := strconv.Atoi(LimitParam)
|
||||||
|
if err != nil {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.service.List(r.Context(), startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive implements domain.RestHandler.
|
||||||
|
func (h *Handler) Archive() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var req domain.ArchiveEntry
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.service.Archive(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode("ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete implements domain.RestHandler.
|
||||||
|
func (h *Handler) HardDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := h.service.HardDelete(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete implements domain.RestHandler.
|
||||||
|
func (h *Handler) SoftDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := h.service.SoftDelete(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCursor implements domain.RestHandler.
|
||||||
|
func (h *Handler) GetCursor() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
cursorId, err := h.service.GetCursor(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(cursorId); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRouter implements domain.RestHandler.
|
||||||
|
func (h *Handler) ApplyRouter() func(chi.Router) {
|
||||||
|
return func(r chi.Router) {
|
||||||
|
if config.Instance().RequireAuth {
|
||||||
|
r.Use(middlewares.Authenticated)
|
||||||
|
}
|
||||||
|
if config.Instance().UseOpenId {
|
||||||
|
r.Use(openid.Middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Get("/", h.List())
|
||||||
|
r.Get("/cursor/{id}", h.GetCursor())
|
||||||
|
r.Post("/", h.Archive())
|
||||||
|
r.Delete("/soft/{id}", h.SoftDelete())
|
||||||
|
r.Delete("/hard/{id}", h.HardDelete())
|
||||||
|
}
|
||||||
|
}
|
||||||
121
server/archive/service/service.go
Normal file
121
server/archive/service/service.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/data"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository domain.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository domain.Repository) domain.Service {
|
||||||
|
return &Service{
|
||||||
|
repository: repository,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive implements domain.Service.
|
||||||
|
func (s *Service) Archive(ctx context.Context, entity *domain.ArchiveEntry) error {
|
||||||
|
return s.repository.Archive(ctx, &data.ArchiveEntry{
|
||||||
|
Id: entity.Id,
|
||||||
|
Title: entity.Title,
|
||||||
|
Path: entity.Path,
|
||||||
|
Thumbnail: entity.Thumbnail,
|
||||||
|
Source: entity.Source,
|
||||||
|
Metadata: entity.Metadata,
|
||||||
|
CreatedAt: entity.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete implements domain.Service.
|
||||||
|
func (s *Service) HardDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) {
|
||||||
|
res, err := s.repository.HardDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.ArchiveEntry{
|
||||||
|
Id: res.Id,
|
||||||
|
Title: res.Title,
|
||||||
|
Path: res.Path,
|
||||||
|
Thumbnail: res.Thumbnail,
|
||||||
|
Source: res.Source,
|
||||||
|
Metadata: res.Metadata,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete implements domain.Service.
|
||||||
|
func (s *Service) SoftDelete(ctx context.Context, id string) (*domain.ArchiveEntry, error) {
|
||||||
|
res, err := s.repository.SoftDelete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.ArchiveEntry{
|
||||||
|
Id: res.Id,
|
||||||
|
Title: res.Title,
|
||||||
|
Path: res.Path,
|
||||||
|
Thumbnail: res.Thumbnail,
|
||||||
|
Source: res.Source,
|
||||||
|
Metadata: res.Metadata,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements domain.Service.
|
||||||
|
func (s *Service) List(
|
||||||
|
ctx context.Context,
|
||||||
|
startRowId int,
|
||||||
|
limit int,
|
||||||
|
) (*domain.PaginatedResponse[[]domain.ArchiveEntry], error) {
|
||||||
|
res, err := s.repository.List(ctx, startRowId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entities := make([]domain.ArchiveEntry, len(*res))
|
||||||
|
|
||||||
|
for i, model := range *res {
|
||||||
|
entities[i] = domain.ArchiveEntry{
|
||||||
|
Id: model.Id,
|
||||||
|
Title: model.Title,
|
||||||
|
Path: model.Path,
|
||||||
|
Thumbnail: model.Thumbnail,
|
||||||
|
Source: model.Source,
|
||||||
|
Metadata: model.Metadata,
|
||||||
|
CreatedAt: model.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
first int64
|
||||||
|
next int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(entities) > 0 {
|
||||||
|
first, err = s.repository.GetCursor(ctx, entities[0].Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err = s.repository.GetCursor(ctx, entities[len(entities)-1].Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.PaginatedResponse[[]domain.ArchiveEntry]{
|
||||||
|
First: first,
|
||||||
|
Next: next,
|
||||||
|
Data: entities,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCursor implements domain.Service.
|
||||||
|
func (s *Service) GetCursor(ctx context.Context, id string) (int64, error) {
|
||||||
|
return s.repository.GetCursor(ctx, id)
|
||||||
|
}
|
||||||
42
server/archiver/archiver.go
Normal file
42
server/archiver/archiver.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package archiver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
evbus "github.com/asaskevich/EventBus"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const QueueName = "process:archive"
|
||||||
|
|
||||||
|
var (
|
||||||
|
eventBus = evbus.New()
|
||||||
|
archiveService archive.Service
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message = archive.Entity
|
||||||
|
|
||||||
|
func Register(db *sql.DB) {
|
||||||
|
_, s := archive.Container(db)
|
||||||
|
archiveService = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
eventBus.Subscribe(QueueName, func(m *Message) {
|
||||||
|
slog.Info(
|
||||||
|
"archiving completed download",
|
||||||
|
slog.String("title", m.Title),
|
||||||
|
slog.String("source", m.Source),
|
||||||
|
)
|
||||||
|
archiveService.Archive(context.Background(), m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Publish(m *Message) {
|
||||||
|
if config.Instance().AutoArchive {
|
||||||
|
eventBus.Publish(QueueName, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type Config struct {
|
|||||||
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
OpenIdClientSecret string `yaml:"openid_client_secret"`
|
||||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||||
FrontendPath string `yaml:"frontend_path"`
|
FrontendPath string `yaml:"frontend_path"`
|
||||||
|
AutoArchive bool `yaml:"auto_archive"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`CREATE TABLE IF NOT EXISTS archive (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(255) NOT NULL,
|
||||||
|
thumbnail TEXT,
|
||||||
|
source VARCHAR(255),
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME
|
||||||
|
)`,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if lockFileExists() {
|
if lockFileExists() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archiver"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ func (p *Process) Start() {
|
|||||||
buildFilename(&p.Output)
|
buildFilename(&p.Output)
|
||||||
|
|
||||||
templateReplacer := strings.NewReplacer("\n", "", "\t", "", " ", "")
|
templateReplacer := strings.NewReplacer("\n", "", "\t", "", " ", "")
|
||||||
|
|
||||||
baseParams := []string{
|
baseParams := []string{
|
||||||
strings.Split(p.Url, "?list")[0], //no playlist
|
strings.Split(p.Url, "?list")[0], //no playlist
|
||||||
"--newline",
|
"--newline",
|
||||||
@@ -193,13 +195,12 @@ func (p *Process) parseLogEntry(entry []byte) {
|
|||||||
if err := json.Unmarshal(entry, &postprocess); err == nil {
|
if err := json.Unmarshal(entry, &postprocess); err == nil {
|
||||||
p.Output.SavedFilePath = postprocess.FilePath
|
p.Output.SavedFilePath = postprocess.FilePath
|
||||||
|
|
||||||
slog.Info("postprocess",
|
// slog.Info("postprocess",
|
||||||
slog.String("id", p.getShortId()),
|
// slog.String("id", p.getShortId()),
|
||||||
slog.String("url", p.Url),
|
// slog.String("url", p.Url),
|
||||||
slog.String("filepath", postprocess.FilePath),
|
// slog.String("filepath", postprocess.FilePath),
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) detectYtDlpErrors(r io.Reader) {
|
func (p *Process) detectYtDlpErrors(r io.Reader) {
|
||||||
@@ -218,6 +219,24 @@ func (p *Process) detectYtDlpErrors(r io.Reader) {
|
|||||||
// Convention: All completed processes has progress -1
|
// Convention: All completed processes has progress -1
|
||||||
// and speed 0 bps.
|
// and speed 0 bps.
|
||||||
func (p *Process) Complete() {
|
func (p *Process) Complete() {
|
||||||
|
// auto archive
|
||||||
|
// TODO: it's not that deterministic :/
|
||||||
|
if p.Progress.Percentage == "" && p.Progress.Speed == 0 {
|
||||||
|
var serializedMetadata bytes.Buffer
|
||||||
|
|
||||||
|
json.NewEncoder(&serializedMetadata).Encode(p.Info)
|
||||||
|
|
||||||
|
archiver.Publish(&archiver.Message{
|
||||||
|
Id: p.Id,
|
||||||
|
Path: p.Output.SavedFilePath,
|
||||||
|
Title: p.Info.Title,
|
||||||
|
Thumbnail: p.Info.Thumbnail,
|
||||||
|
Source: p.Url,
|
||||||
|
Metadata: serializedMetadata.String(),
|
||||||
|
CreatedAt: p.Info.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
p.Progress = DownloadProgress{
|
p.Progress = DownloadProgress{
|
||||||
Status: StatusCompleted,
|
Status: StatusCompleted,
|
||||||
Percentage: "-1",
|
Percentage: "-1",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archive"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archiver"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/dbutil"
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/dbutil"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/handlers"
|
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/handlers"
|
||||||
@@ -145,6 +147,8 @@ func RunBlocking(rc *RunConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newServer(c serverConfig) *http.Server {
|
func newServer(c serverConfig) *http.Server {
|
||||||
|
archiver.Register(c.db)
|
||||||
|
|
||||||
service := ytdlpRPC.Container(c.mdb, c.mq, c.lm)
|
service := ytdlpRPC.Container(c.mdb, c.mq, c.lm)
|
||||||
rpc.Register(service)
|
rpc.Register(service)
|
||||||
|
|
||||||
@@ -174,8 +178,8 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
// swagger
|
// swagger
|
||||||
r.Mount("/openapi", http.FileServerFS(c.swagger))
|
r.Mount("/openapi", http.FileServerFS(c.swagger))
|
||||||
|
|
||||||
// Archive routes
|
// Filebrowser routes
|
||||||
r.Route("/archive", func(r chi.Router) {
|
r.Route("/filebrowser", func(r chi.Router) {
|
||||||
if config.Instance().RequireAuth {
|
if config.Instance().RequireAuth {
|
||||||
r.Use(middlewares.Authenticated)
|
r.Use(middlewares.Authenticated)
|
||||||
}
|
}
|
||||||
@@ -189,6 +193,9 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
r.Get("/bulk", handlers.BulkDownload(c.mdb))
|
r.Get("/bulk", handlers.BulkDownload(c.mdb))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Archive routes
|
||||||
|
r.Route("/archive", archive.ApplyRouter(c.db))
|
||||||
|
|
||||||
// Authentication routes
|
// Authentication routes
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Post("/login", handlers.Login)
|
r.Post("/login", handlers.Login)
|
||||||
|
|||||||
Reference in New Issue
Block a user