Added better archive functionalty (backend side atm)

Code refactoring
This commit is contained in:
2024-12-18 11:59:17 +01:00
parent d9cb018132
commit 9d3861ab39
29 changed files with 1401 additions and 417 deletions

View File

@@ -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={
{

View 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

View File

@@ -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>

View File

@@ -348,7 +348,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
disabled={url === ''}
onClick={() => settings.formatSelection
? startTransition(() => sendUrlFormatSelection())
: sendUrl()
: startTransition(async () => await sendUrl())
}
>
{

View File

@@ -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>
</>
)
}

View File

@@ -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

View 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>
)
}

View 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

View File

@@ -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>

View File

@@ -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: [],
})

View File

@@ -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: (

View File

@@ -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
}

View File

@@ -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]
}
}

View File

@@ -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

View 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
View 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()
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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())
}
}

View 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)
}

View 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)
}
}

View File

@@ -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 (

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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)