From 9d3861ab399688923e47a0c531f56a2c3f2d9256 Mon Sep 17 00:00:00 2001 From: marcobaobao Date: Wed, 18 Dec 2024 11:59:17 +0100 Subject: [PATCH] Added better archive functionalty (backend side atm) Code refactoring --- frontend/src/Layout.tsx | 18 +- frontend/src/components/ArchiveCard.tsx | 98 ++++ frontend/src/components/DownloadCard.tsx | 83 ++-- frontend/src/components/DownloadDialog.tsx | 2 +- frontend/src/components/DownloadsGridView.tsx | 43 +- .../src/components/DownloadsTableView.tsx | 4 +- frontend/src/components/EmptyArchive.tsx | 45 ++ frontend/src/components/ResolutionBadge.tsx | 15 + frontend/src/components/TemplatesEditor.tsx | 2 +- frontend/src/lib/rpcClient.ts | 6 +- frontend/src/router.tsx | 14 + frontend/src/types/index.ts | 16 + frontend/src/utils.ts | 10 +- frontend/src/views/Archive.tsx | 423 ++++-------------- frontend/src/views/Filebrowser.tsx | 360 +++++++++++++++ server/archive/archive.go | 18 + server/archive/container.go | 16 + server/archive/data/models.go | 13 + server/archive/domain/archive.go | 51 +++ server/archive/provider.go | 42 ++ server/archive/repository/repository.go | 156 +++++++ server/archive/rest/handler.go | 162 +++++++ server/archive/service/service.go | 121 +++++ server/archiver/archiver.go | 42 ++ server/config/config.go | 1 + server/dbutil/migrate.go | 15 + .../handlers/{archive.go => filebrowser.go} | 0 server/internal/process.go | 31 +- server/server.go | 11 +- 29 files changed, 1401 insertions(+), 417 deletions(-) create mode 100644 frontend/src/components/ArchiveCard.tsx create mode 100644 frontend/src/components/EmptyArchive.tsx create mode 100644 frontend/src/components/ResolutionBadge.tsx create mode 100644 frontend/src/views/Filebrowser.tsx create mode 100644 server/archive/archive.go create mode 100644 server/archive/container.go create mode 100644 server/archive/data/models.go create mode 100644 server/archive/domain/archive.go create mode 100644 server/archive/provider.go create mode 100644 server/archive/repository/repository.go create mode 100644 server/archive/rest/handler.go create mode 100644 server/archive/service/service.go create mode 100644 server/archiver/archiver.go rename server/handlers/{archive.go => filebrowser.go} (100%) diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index ac09c5e..278ae33 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -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() { - + */} + + + + + + + void + onHardDelete: (id: string) => void +} + +const ArchiveCard: React.FC = ({ 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 ( + + navigator.clipboard.writeText(entry.source)}> + {entry.thumbnail !== '' ? + : + + } + + {entry.title !== '' ? + + {ellipsis(entry.title, 60)} + : + + } + {/* + {JSON.stringify(JSON.parse(entry.metadata), null, 2)} + */} +

{new Date(entry.created_at).toLocaleString()}

+
+
+ + + viewFile(entry.path)} + > + + + + + downloadFile(entry.path)} + > + + + + + onDelete(entry.id)} + > + + + + + onHardDelete(entry.id)} + > + + + + +
+ ) +} + +export default ArchiveCard \ No newline at end of file diff --git a/frontend/src/components/DownloadCard.tsx b/frontend/src/components/DownloadCard.tsx index 3ec2785..723fa92 100644 --- a/frontend/src/components/DownloadCard.tsx +++ b/frontend/src/components/DownloadCard.tsx @@ -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 - if (resolution.includes('2160')) return - if (resolution.includes('1080')) return - if (resolution.includes('720')) return - return null -} - const DownloadCard: React.FC = ({ download, onStop, onCopy }) => { const serverAddr = useAtomValue(serverURL) @@ -53,12 +47,12 @@ const DownloadCard: React.FC = ({ 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 = ({ download, onStop, onCopy }) => { {formatSize(download.info.filesize_approx ?? 0)} - + - + {isCompleted() ? + + + + + + : + + + + + + } {isCompleted() && <> - - + + downloadFile(download.output.savedFilePath)} + > + + + + + viewFile(download.output.savedFilePath)} + > + + + } diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index bff0d8f..8e917df 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -348,7 +348,7 @@ const DownloadDialog: FC = ({ open, onClose, onDownloadStart }) => { disabled={url === ''} onClick={() => settings.formatSelection ? startTransition(() => sendUrlFormatSelection()) - : sendUrl() + : startTransition(async () => await sendUrl()) } > { diff --git a/frontend/src/components/DownloadsGridView.tsx b/frontend/src/components/DownloadsGridView.tsx index 1f97b14..fea2f9c 100644 --- a/frontend/src/components/DownloadsGridView.tsx +++ b/frontend/src/components/DownloadsGridView.tsx @@ -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 ( - - { - downloads.map(download => ( - - stop(download)} - onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')} - /> - - )) - } - + <> + + + { + downloads.map(download => ( + + startTransition(async () => { + await stop(download) + })} + onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')} + /> + + )) + } + + ) } diff --git a/frontend/src/components/DownloadsTableView.tsx b/frontend/src/components/DownloadsTableView.tsx index c9c3b06..3666544 100644 --- a/frontend/src/components/DownloadsTableView.tsx +++ b/frontend/src/components/DownloadsTableView.tsx @@ -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 diff --git a/frontend/src/components/EmptyArchive.tsx b/frontend/src/components/EmptyArchive.tsx new file mode 100644 index 0000000..40da9ed --- /dev/null +++ b/frontend/src/components/EmptyArchive.tsx @@ -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 ( + + + <SvgIcon sx={{ fontSize: '200px' }}> + <ArchiveIcon /> + </SvgIcon> + + + {/* {i18n.t('splashText')} */} + Empty Archive + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ResolutionBadge.tsx b/frontend/src/components/ResolutionBadge.tsx new file mode 100644 index 0000000..7a7dd47 --- /dev/null +++ b/frontend/src/components/ResolutionBadge.tsx @@ -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 + if (resolution.includes('2160')) return + if (resolution.includes('1080')) return + if (resolution.includes('720')) return + return null +} + +export default ResolutionBadge \ No newline at end of file diff --git a/frontend/src/components/TemplatesEditor.tsx b/frontend/src/components/TemplatesEditor.tsx index 5277152..9d193da 100644 --- a/frontend/src/components/TemplatesEditor.tsx +++ b/frontend/src/components/TemplatesEditor.tsx @@ -201,7 +201,7 @@ const TemplatesEditor: React.FC = ({ open, onClose }) => { InputProps={{ endAdornment: diff --git a/frontend/src/lib/rpcClient.ts b/frontend/src/lib/rpcClient.ts index 188e40b..aedcd2a 100644 --- a/frontend/src/lib/rpcClient.ts +++ b/frontend/src/lib/rpcClient.ts @@ -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: [], }) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index e48a52f..dbee30a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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([ ) }, + { + path: '/filebrowser', + element: ( + }> + + + ), + errorElement: ( + }> + + + ) + }, { path: '/login', element: ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 279f41a..1c031f2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -122,4 +122,20 @@ export type LiveStreamProgress = Record = { + first: number + next: number + data: T } \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index b9e9da8..e8f5fa5 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -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] } } \ No newline at end of file diff --git a/frontend/src/views/Archive.tsx b/frontend/src/views/Archive.tsx index 751208f..b557aed 100644 --- a/frontend/src/views/Archive.tsx +++ b/frontend/src/views/Archive.tsx @@ -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() +const Archive: React.FC = () => { + const [isLoading, setLoading] = useState(true) + const [archiveEntries, setArchiveEntries] = useState() - const serverAddr = useAtomValue(serverURL) - const navigate = useNavigate() - - const { i18n } = useI18n() - const { pushMessage } = useToast() - - const [openDialog, setOpenDialog] = useState(false) - - const files$ = useMemo(() => new Subject(), []) - const selected$ = useMemo(() => new BehaviorSubject([]), []) + 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( - `${serverAddr}/archive/downloaded`, - { - method: 'POST', - body: JSON.stringify({ - subdir: '', - }) - } - ), + const serverAddr = useAtomValue(serverURL) + const { pushMessage } = useToast() + + const fetchArchived = (startCursor = 0) => pipe( + ffetch>(`${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(`${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(`${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(`${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 ( - setShowMenu(false)} - > - { - if (currentFile) { - downloadFile(currentFile?.path) - setCurrentFile(undefined) - } - }} - onDelete={() => { - if (currentFile) { - deleteFile(currentFile) - setCurrentFile(undefined) - } - }} - /> - theme.zIndex.drawer + 1 }} - open={!(files$.observed) || isPending} - > - - - setShowMenu(false)} - > - - {i18n.t('archiveTitle')} - - - {selectable.length === 0 && 'No files found'} - {selectable.map((file, idx) => ( - { - e.preventDefault() - setCurrentFile(file) - setMenuPos({ x: e.clientX, y: e.clientY }) - setShowMenu(true) - }} - key={idx} - secondaryAction={ -
- {!file.isDirectory && - {formatSize(file.size)} - - } - {!file.isDirectory && <> - addSelected(file.name)} - /> - } -
- } - disablePadding - > - file.isDirectory - ? onFolderClick(file.path) - : onFileClick(file.path) - }> - - {file.isDirectory - ? - : file.isVideo - ? - : - } - - - -
- ))} -
-
- } - > - } - tooltipTitle={`Delete selected`} - tooltipOpen - onClick={() => { - if (selected$.value.length > 0) { - setOpenDialog(true) - } - }} - /> - - setOpenDialog(false)} - > - - Are you sure? - - - - You're deleting: - -
    - {selected$.value.map((entry, idx) => ( -
  • {entry}
  • - ))} -
-
- - - - -
+ { + archiveEntries.map((entry) => ( + + startTransition(async () => await softDelete(entry.id))} + onHardDelete={() => startTransition(async () => await hardDelete(entry.id))} + /> + + )) + } + + : + } + setPage(v)} + /> + + Page size + +
) } -const IconMenu: React.FC<{ - posX: number - posY: number - hide: boolean - onDownload: () => void - onDelete: () => void -}> = ({ posX, posY, hide, onDelete, onDownload }) => { - return ( - theme.zIndex.drawer + 1, - }}> - - - - - - - Download - - - - - - - - Delete - - - - - ) -} \ No newline at end of file +export default Archive \ No newline at end of file diff --git a/frontend/src/views/Filebrowser.tsx b/frontend/src/views/Filebrowser.tsx new file mode 100644 index 0000000..3fab161 --- /dev/null +++ b/frontend/src/views/Filebrowser.tsx @@ -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() + + const serverAddr = useAtomValue(serverURL) + const navigate = useNavigate() + + const { i18n } = useI18n() + const { pushMessage } = useToast() + + const [openDialog, setOpenDialog] = useState(false) + + const files$ = useMemo(() => new Subject(), []) + const selected$ = useMemo(() => new BehaviorSubject([]), []) + + const [isPending, startTransition] = useTransition() + + const fetcher = () => pipe( + ffetch( + `${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(`${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 ( + setShowMenu(false)} + > + { + if (currentFile) { + downloadFile(currentFile?.path) + setCurrentFile(undefined) + } + }} + onDelete={() => { + if (currentFile) { + deleteFile(currentFile) + setCurrentFile(undefined) + } + }} + /> + theme.zIndex.drawer + 1 }} + open={!(files$.observed) || isPending} + > + + + setShowMenu(false)} + > + + {selectable.length === 0 && 'No files found'} + {selectable.map((file, idx) => ( + { + e.preventDefault() + setCurrentFile(file) + setMenuPos({ x: e.clientX, y: e.clientY }) + setShowMenu(true) + }} + key={idx} + secondaryAction={ +
+ {!file.isDirectory && + {formatSize(file.size)} + + } + {!file.isDirectory && <> + addSelected(file.name)} + /> + } +
+ } + disablePadding + > + file.isDirectory + ? onFolderClick(file.path) + : onFileClick(file.path) + }> + + {file.isDirectory + ? + : file.isVideo + ? + : + } + + + +
+ ))} +
+
+ } + > + } + tooltipTitle={`Delete selected`} + tooltipOpen + onClick={() => { + if (selected$.value.length > 0) { + setOpenDialog(true) + } + }} + /> + + setOpenDialog(false)} + > + + Are you sure? + + + + You're deleting: + +
    + {selected$.value.map((entry, idx) => ( +
  • {entry}
  • + ))} +
+
+ + + + +
+
+ ) +} + +const IconMenu: React.FC<{ + posX: number + posY: number + hide: boolean + onDownload: () => void + onDelete: () => void +}> = ({ posX, posY, hide, onDelete, onDownload }) => { + return ( + theme.zIndex.drawer + 1, + }}> + + + + + + + Download + + + + + + + + Delete + + + + + ) +} \ No newline at end of file diff --git a/server/archive/archive.go b/server/archive/archive.go new file mode 100644 index 0000000..f3c3ea6 --- /dev/null +++ b/server/archive/archive.go @@ -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() +} diff --git a/server/archive/container.go b/server/archive/container.go new file mode 100644 index 0000000..345f559 --- /dev/null +++ b/server/archive/container.go @@ -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 +} diff --git a/server/archive/data/models.go b/server/archive/data/models.go new file mode 100644 index 0000000..dc0567a --- /dev/null +++ b/server/archive/data/models.go @@ -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 +} diff --git a/server/archive/domain/archive.go b/server/archive/domain/archive.go new file mode 100644 index 0000000..6deb171 --- /dev/null +++ b/server/archive/domain/archive.go @@ -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) +} diff --git a/server/archive/provider.go b/server/archive/provider.go new file mode 100644 index 0000000..c525934 --- /dev/null +++ b/server/archive/provider.go @@ -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 +} diff --git a/server/archive/repository/repository.go b/server/archive/repository/repository.go new file mode 100644 index 0000000..cd5d749 --- /dev/null +++ b/server/archive/repository/repository.go @@ -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 +} diff --git a/server/archive/rest/handler.go b/server/archive/rest/handler.go new file mode 100644 index 0000000..a297eb0 --- /dev/null +++ b/server/archive/rest/handler.go @@ -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()) + } +} diff --git a/server/archive/service/service.go b/server/archive/service/service.go new file mode 100644 index 0000000..a81a69a --- /dev/null +++ b/server/archive/service/service.go @@ -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) +} diff --git a/server/archiver/archiver.go b/server/archiver/archiver.go new file mode 100644 index 0000000..59bdf8b --- /dev/null +++ b/server/archiver/archiver.go @@ -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) + } +} diff --git a/server/config/config.go b/server/config/config.go index c36f3f4..1a60407 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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 ( diff --git a/server/dbutil/migrate.go b/server/dbutil/migrate.go index fea3f8a..de6f098 100644 --- a/server/dbutil/migrate.go +++ b/server/dbutil/migrate.go @@ -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 } diff --git a/server/handlers/archive.go b/server/handlers/filebrowser.go similarity index 100% rename from server/handlers/archive.go rename to server/handlers/filebrowser.go diff --git a/server/internal/process.go b/server/internal/process.go index 69ccf05..b06a72a 100644 --- a/server/internal/process.go +++ b/server/internal/process.go @@ -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", diff --git a/server/server.go b/server/server.go index 6780dc8..d4ff6fe 100644 --- a/server/server.go +++ b/server/server.go @@ -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)