Added better archive functionalty (backend side atm)
Code refactoring
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ThemeProvider } from '@emotion/react'
|
||||
import ArchiveIcon from '@mui/icons-material/Archive'
|
||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||
import Dashboard from '@mui/icons-material/Dashboard'
|
||||
import LiveTvIcon from '@mui/icons-material/LiveTv'
|
||||
@@ -42,7 +43,7 @@ export default function Layout() {
|
||||
palette: {
|
||||
mode: settings.theme,
|
||||
primary: {
|
||||
main: getAccentValue(settings.accent)
|
||||
main: getAccentValue(settings.accent, settings.theme)
|
||||
},
|
||||
background: {
|
||||
default: settings.theme === 'light' ? grey[50] : '#121212'
|
||||
@@ -113,7 +114,7 @@ export default function Layout() {
|
||||
<ListItemText primary={i18n.t('homeButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/archive'} style={
|
||||
{/* <Link to={'/archive'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
@@ -125,6 +126,19 @@ export default function Layout() {
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||
</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 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 {
|
||||
Button,
|
||||
Card,
|
||||
@@ -10,16 +6,23 @@ import {
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback } from 'react'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import { RPCResult } from '../types'
|
||||
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 = {
|
||||
download: RPCResult
|
||||
@@ -27,15 +30,6 @@ type Props = {
|
||||
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 serverAddr = useAtomValue(serverURL)
|
||||
|
||||
@@ -53,12 +47,12 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||
|
||||
const viewFile = (path: string) => {
|
||||
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 encoded = base64URLEncode(path)
|
||||
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,37 +104,44 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||
<Typography>
|
||||
{formatSize(download.info.filesize_approx ?? 0)}
|
||||
</Typography>
|
||||
<Resolution resolution={download.info.resolution} />
|
||||
<ResolutionBadge resolution={download.info.resolution} />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={onStop}
|
||||
>
|
||||
{isCompleted() ? "Clear" : "Stop"}
|
||||
</Button>
|
||||
{isCompleted() ?
|
||||
<Tooltip title="Clear from the view">
|
||||
<IconButton
|
||||
onClick={onStop}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
:
|
||||
<Tooltip title="Stop this download">
|
||||
<IconButton
|
||||
onClick={onStop}
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
{isCompleted() &&
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => viewFile(download.output.savedFilePath)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Tooltip title="Download this file">
|
||||
<IconButton
|
||||
onClick={() => downloadFile(download.output.savedFilePath)}
|
||||
>
|
||||
<SaveAltIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open in a new tab">
|
||||
<IconButton
|
||||
onClick={() => viewFile(download.output.savedFilePath)}
|
||||
>
|
||||
<OpenInBrowserIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
</CardActions>
|
||||
|
||||
@@ -348,7 +348,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
disabled={url === ''}
|
||||
onClick={() => settings.formatSelection
|
||||
? 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 { useTransition } from 'react'
|
||||
import { activeDownloadsState } from '../atoms/downloads'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { useRPC } from '../hooks/useRPC'
|
||||
import { ProcessStatus, RPCResult } from '../types'
|
||||
import DownloadCard from './DownloadCard'
|
||||
import LoadingBackdrop from './LoadingBackdrop'
|
||||
|
||||
const DownloadsGridView: React.FC = () => {
|
||||
const downloads = useAtomValue(activeDownloadsState)
|
||||
@@ -14,24 +16,31 @@ const DownloadsGridView: React.FC = () => {
|
||||
const { client } = useRPC()
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||
? client.clear(r.id)
|
||||
: client.kill(r.id)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
|
||||
? await client.clear(r.id)
|
||||
: await client.kill(r.id)
|
||||
|
||||
return (
|
||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
||||
{
|
||||
downloads.map(download => (
|
||||
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
|
||||
<DownloadCard
|
||||
download={download}
|
||||
onStop={() => stop(download)}
|
||||
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
}
|
||||
</Grid>
|
||||
<>
|
||||
<LoadingBackdrop isLoading={isPending} />
|
||||
<Grid2 container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
|
||||
{
|
||||
downloads.map(download => (
|
||||
<Grid2 size={{ xs: 4, sm: 8, md: 6, xl: 4 }} key={download.id}>
|
||||
<DownloadCard
|
||||
download={download}
|
||||
onStop={() => startTransition(async () => {
|
||||
await stop(download)
|
||||
})}
|
||||
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
||||
/>
|
||||
</Grid2>
|
||||
))
|
||||
}
|
||||
</Grid2>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -125,12 +125,12 @@ const DownloadsTableView: React.FC = () => {
|
||||
|
||||
const viewFile = (path: string) => {
|
||||
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 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
|
||||
|
||||
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={{
|
||||
endAdornment: <Button
|
||||
variant='contained'
|
||||
onClick={() => startTransition(() => { addTemplate() })}
|
||||
onClick={() => startTransition(async () => await addTemplate())}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
|
||||
@@ -128,21 +128,21 @@ export class RPCClient {
|
||||
}
|
||||
|
||||
public kill(id: string) {
|
||||
this.sendHTTP({
|
||||
return this.sendHTTP({
|
||||
method: 'Service.Kill',
|
||||
params: [id],
|
||||
})
|
||||
}
|
||||
|
||||
public clear(id: string) {
|
||||
this.sendHTTP({
|
||||
return this.sendHTTP({
|
||||
method: 'Service.Clear',
|
||||
params: [id],
|
||||
})
|
||||
}
|
||||
|
||||
public killAll() {
|
||||
this.sendHTTP({
|
||||
return this.sendHTTP({
|
||||
method: 'Service.KillAll',
|
||||
params: [],
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ const Login = lazy(() => import('./views/Login'))
|
||||
const Archive = lazy(() => import('./views/Archive'))
|
||||
const Settings = lazy(() => import('./views/Settings'))
|
||||
const LiveStream = lazy(() => import('./views/Livestream'))
|
||||
const Filebrowser = lazy(() => import('./views/Filebrowser'))
|
||||
|
||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||
|
||||
@@ -59,6 +60,19 @@ export const router = createHashRouter([
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/filebrowser',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Filebrowser />
|
||||
</Suspense >
|
||||
),
|
||||
errorElement: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<ErrorBoundary />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
|
||||
@@ -122,4 +122,20 @@ export type LiveStreamProgress = Record<string, {
|
||||
export type RPCVersion = {
|
||||
rpcVersion: 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 { pipe } from 'fp-ts/lib/function'
|
||||
import { Accent } from './atoms/settings'
|
||||
import { Accent, ThemeNarrowed } from './atoms/settings'
|
||||
import type { RPCResponse } from "./types"
|
||||
import { ProcessStatus } from './types'
|
||||
|
||||
@@ -83,13 +83,13 @@ export const base64URLEncode = (s: string) => pipe(
|
||||
encodeURIComponent
|
||||
)
|
||||
|
||||
export const getAccentValue = (accent: Accent) => {
|
||||
export const getAccentValue = (accent: Accent, mode: ThemeNarrowed) => {
|
||||
switch (accent) {
|
||||
case 'default':
|
||||
return blue[700]
|
||||
return mode === 'light' ? blue[700] : blue[300]
|
||||
case 'red':
|
||||
return red[600]
|
||||
return mode === 'light' ? red[600] : red[400]
|
||||
default:
|
||||
return blue[700]
|
||||
return mode === 'light' ? blue[700] : blue[300]
|
||||
}
|
||||
}
|
||||
@@ -1,363 +1,112 @@
|
||||
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 { Container, FormControl, Grid2, InputLabel, MenuItem, Pagination, Select } from '@mui/material'
|
||||
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 { matchW } from 'fp-ts/lib/TaskEither'
|
||||
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 [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
||||
const Archive: React.FC = () => {
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [archiveEntries, setArchiveEntries] = useState<ArchiveEntry[]>()
|
||||
|
||||
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 [currentCursor, setCurrentCursor] = useState(0)
|
||||
const [cursor, setCursor] = useState({ first: 0, next: 0 })
|
||||
const [pageSize, setPageSize] = useState(25)
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const fetcher = () => pipe(
|
||||
ffetch<DirectoryEntry[]>(
|
||||
`${serverAddr}/archive/downloaded`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subdir: '',
|
||||
})
|
||||
}
|
||||
),
|
||||
const serverAddr = useAtomValue(serverURL)
|
||||
const { pushMessage } = useToast()
|
||||
|
||||
const fetchArchived = (startCursor = 0) => pipe(
|
||||
ffetch<PaginatedResponse<ArchiveEntry[]>>(`${serverAddr}/archive?id=${startCursor}&limit=${pageSize}`),
|
||||
matchW(
|
||||
(e) => {
|
||||
pushMessage(e, 'error')
|
||||
navigate('/login')
|
||||
},
|
||||
(d) => files$.next(d ?? []),
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(r) => {
|
||||
setArchiveEntries(r.data)
|
||||
setCursor({ ...cursor, first: r.first, next: r.next })
|
||||
}
|
||||
)
|
||||
)()
|
||||
|
||||
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}/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,
|
||||
})
|
||||
const softDelete = (id: string) => pipe(
|
||||
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/soft/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => fetcher()
|
||||
(_) => startTransition(async () => await fetchArchived())
|
||||
)
|
||||
)()
|
||||
|
||||
const deleteSelected = () => {
|
||||
Promise.all(selectable
|
||||
.filter(entry => entry.selected)
|
||||
.map(deleteFile)
|
||||
).then(fetcher)
|
||||
}
|
||||
const hardDelete = (id: string) => pipe(
|
||||
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/hard/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => startTransition(async () => await fetchArchived())
|
||||
)
|
||||
)()
|
||||
|
||||
const setPage = (page: number) => setCurrentCursor(pageSize * (page - 1))
|
||||
|
||||
useEffect(() => {
|
||||
fetcher()
|
||||
}, [serverAddr])
|
||||
|
||||
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)
|
||||
})
|
||||
fetchArchived(currentCursor).then(() => setLoading(false))
|
||||
}, [currentCursor])
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{ mt: 4, mb: 4, height: '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)}
|
||||
>
|
||||
<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
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 8, minHeight: '80vh' }}>
|
||||
<LoadingBackdrop isLoading={isPending || isLoading} />
|
||||
{
|
||||
archiveEntries && archiveEntries.length !== 0 ?
|
||||
<Grid2
|
||||
container
|
||||
spacing={{ xs: 2, md: 2 }}
|
||||
columns={{ xs: 4, sm: 8, md: 12, xl: 12 }}
|
||||
pt={2}
|
||||
sx={{ minHeight: '77.5vh' }}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
{
|
||||
archiveEntries.map((entry) => (
|
||||
<Grid2 size={{ xs: 4, sm: 8, md: 4, xl: 3 }} key={entry.id}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
export default Archive
|
||||
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"`
|
||||
OpenIdRedirectURL string `yaml:"openid_redirect_url"`
|
||||
FrontendPath string `yaml:"frontend_path"`
|
||||
AutoArchive bool `yaml:"auto_archive"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -34,6 +34,21 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/archiver"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
|
||||
)
|
||||
|
||||
@@ -87,6 +88,7 @@ func (p *Process) Start() {
|
||||
buildFilename(&p.Output)
|
||||
|
||||
templateReplacer := strings.NewReplacer("\n", "", "\t", "", " ", "")
|
||||
|
||||
baseParams := []string{
|
||||
strings.Split(p.Url, "?list")[0], //no playlist
|
||||
"--newline",
|
||||
@@ -193,13 +195,12 @@ func (p *Process) parseLogEntry(entry []byte) {
|
||||
if err := json.Unmarshal(entry, &postprocess); err == nil {
|
||||
p.Output.SavedFilePath = postprocess.FilePath
|
||||
|
||||
slog.Info("postprocess",
|
||||
slog.String("id", p.getShortId()),
|
||||
slog.String("url", p.Url),
|
||||
slog.String("filepath", postprocess.FilePath),
|
||||
)
|
||||
// slog.Info("postprocess",
|
||||
// slog.String("id", p.getShortId()),
|
||||
// slog.String("url", p.Url),
|
||||
// slog.String("filepath", postprocess.FilePath),
|
||||
// )
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
// and speed 0 bps.
|
||||
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{
|
||||
Status: StatusCompleted,
|
||||
Percentage: "-1",
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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/dbutil"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/handlers"
|
||||
@@ -145,6 +147,8 @@ func RunBlocking(rc *RunConfig) {
|
||||
}
|
||||
|
||||
func newServer(c serverConfig) *http.Server {
|
||||
archiver.Register(c.db)
|
||||
|
||||
service := ytdlpRPC.Container(c.mdb, c.mq, c.lm)
|
||||
rpc.Register(service)
|
||||
|
||||
@@ -174,8 +178,8 @@ func newServer(c serverConfig) *http.Server {
|
||||
// swagger
|
||||
r.Mount("/openapi", http.FileServerFS(c.swagger))
|
||||
|
||||
// Archive routes
|
||||
r.Route("/archive", func(r chi.Router) {
|
||||
// Filebrowser routes
|
||||
r.Route("/filebrowser", func(r chi.Router) {
|
||||
if config.Instance().RequireAuth {
|
||||
r.Use(middlewares.Authenticated)
|
||||
}
|
||||
@@ -189,6 +193,9 @@ func newServer(c serverConfig) *http.Server {
|
||||
r.Get("/bulk", handlers.BulkDownload(c.mdb))
|
||||
})
|
||||
|
||||
// Archive routes
|
||||
r.Route("/archive", archive.ApplyRouter(c.db))
|
||||
|
||||
// Authentication routes
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", handlers.Login)
|
||||
|
||||
Reference in New Issue
Block a user