From 205f2e5cdf955736a347a734500d162fceb85868 Mon Sep 17 00:00:00 2001 From: marcobaobao Date: Tue, 16 Apr 2024 11:27:47 +0200 Subject: [PATCH] Implemented "download file" in dashboard and bulk download closes #115 --- frontend/src/assets/i18n.yaml | 12 +++++ frontend/src/components/DownloadCard.tsx | 36 ++++++++++++- .../src/components/DownloadsTableView.tsx | 49 ++++++++++++++---- frontend/src/components/HomeSpeedDial.tsx | 11 +++- frontend/src/types/index.ts | 3 ++ server/handlers/archive.go | 51 +++++++++++++++++++ server/internal/process.go | 25 ++++++++- server/server.go | 1 + 8 files changed, 174 insertions(+), 14 deletions(-) diff --git a/frontend/src/assets/i18n.yaml b/frontend/src/assets/i18n.yaml index fd8f302..cdcb7dd 100644 --- a/frontend/src/assets/i18n.yaml +++ b/frontend/src/assets/i18n.yaml @@ -49,6 +49,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' german: urlInput: Video URL statusTitle: Status @@ -98,6 +99,7 @@ languages: templatesEditorContentLabel: Vorlagen Inhalt logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' french: urlInput: URL vidéo de YouTube ou d'un autre service pris en charge statusTitle: Statut @@ -149,6 +151,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' italian: urlInput: URL Video (uno per linea) statusTitle: Stato @@ -197,6 +200,7 @@ languages: templatesEditorContentLabel: Contentunto template logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' chinese: urlInput: 视频 URL statusTitle: 状态 @@ -246,6 +250,7 @@ languages: templatesEditorContentLabel: 模板内容 logsTitle: '日志' awaitingLogs: '正在等待日志…' + bulkDownload: 'Download files in a zip archive' spanish: urlInput: URL de YouTube u otro servicio compatible statusTitle: Estado @@ -293,6 +298,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' russian: urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса statusTitle: Статус @@ -340,6 +346,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' korean: urlInput: YouTube나 다른 지원되는 사이트의 URL statusTitle: 상태 @@ -387,6 +394,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' japanese: urlInput: YouTubeまたはサポート済み動画のURL statusTitle: 状態 @@ -435,6 +443,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' catalan: urlInput: URL de YouTube o d'un altre servei compatible statusTitle: Estat @@ -482,6 +491,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' ukrainian: urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу statusTitle: Статус @@ -529,6 +539,7 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' polish: urlInput: Adres URL YouTube lub innej obsługiwanej usługi statusTitle: Status @@ -576,3 +587,4 @@ languages: templatesEditorContentLabel: Template content logsTitle: 'Logs' awaitingLogs: 'Awaiting logs...' + bulkDownload: 'Download files in a zip archive' \ No newline at end of file diff --git a/frontend/src/components/DownloadCard.tsx b/frontend/src/components/DownloadCard.tsx index b4f8298..efbb0d5 100644 --- a/frontend/src/components/DownloadCard.tsx +++ b/frontend/src/components/DownloadCard.tsx @@ -16,8 +16,10 @@ import { Typography } from '@mui/material' import { useCallback } from 'react' +import { useRecoilValue } from 'recoil' +import { serverURL } from '../atoms/settings' import { RPCResult } from '../types' -import { ellipsis, formatSpeedMiB, mapProcessStatus, formatSize } from '../utils' +import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils' type Props = { download: RPCResult @@ -35,6 +37,8 @@ const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => { } const DownloadCard: React.FC = ({ download, onStop, onCopy }) => { + const serverAddr = useRecoilValue(serverURL) + const isCompleted = useCallback( () => download.progress.percentage === '-1', [download.progress.percentage] @@ -47,6 +51,16 @@ const DownloadCard: React.FC = ({ download, onStop, onCopy }) => { [download.progress.percentage, isCompleted] ) + const viewFile = (path: string) => { + const encoded = base64URLEncode(path) + window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`) + } + + const downloadFile = (path: string) => { + const encoded = base64URLEncode(path) + window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`) + } + return ( { @@ -109,6 +123,26 @@ const DownloadCard: React.FC = ({ download, onStop, onCopy }) => { > {isCompleted() ? "Clear" : "Stop"} + {isCompleted() && + <> + + + + } ) diff --git a/frontend/src/components/DownloadsTableView.tsx b/frontend/src/components/DownloadsTableView.tsx index e414c5c..9330733 100644 --- a/frontend/src/components/DownloadsTableView.tsx +++ b/frontend/src/components/DownloadsTableView.tsx @@ -1,14 +1,15 @@ import DeleteIcon from '@mui/icons-material/Delete' import DownloadIcon from '@mui/icons-material/Download' import DownloadDoneIcon from '@mui/icons-material/DownloadDone' +import FileDownloadIcon from '@mui/icons-material/FileDownload' +import SmartDisplayIcon from '@mui/icons-material/SmartDisplay' import StopCircleIcon from '@mui/icons-material/StopCircle' import { Box, - Grid, + ButtonGroup, IconButton, LinearProgress, LinearProgressProps, - Paper, Table, TableBody, TableCell, @@ -19,8 +20,9 @@ import { } from "@mui/material" import { useRecoilValue } from 'recoil' import { activeDownloadsState } from '../atoms/downloads' +import { serverURL } from '../atoms/settings' import { useRPC } from '../hooks/useRPC' -import { formatSize, formatSpeedMiB } from "../utils" +import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils" function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) { return ( @@ -38,12 +40,23 @@ function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) } const DownloadsTableView: React.FC = () => { + const serverAddr = useRecoilValue(serverURL) const downloads = useRecoilValue(activeDownloadsState) const { client } = useRPC() const abort = (id: string) => client.kill(id) + const viewFile = (path: string) => { + const encoded = base64URLEncode(path) + window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`) + } + + const downloadFile = (path: string) => { + const encoded = base64URLEncode(path) + window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`) + } + return ( { {new Date(download.info.created_at).toLocaleString()} - abort(download.id)} - > - {download.progress.percentage === '-1' ? : } + + abort(download.id)} + > + {download.progress.percentage === '-1' ? : } - + + {download.progress.percentage === '-1' && + <> + viewFile(download.output.savedFilePath)} + > + + + downloadFile(download.output.savedFilePath)} + > + + + + } + )) diff --git a/frontend/src/components/HomeSpeedDial.tsx b/frontend/src/components/HomeSpeedDial.tsx index 31e62be..65e860b 100644 --- a/frontend/src/components/HomeSpeedDial.tsx +++ b/frontend/src/components/HomeSpeedDial.tsx @@ -3,13 +3,14 @@ import BuildCircleIcon from '@mui/icons-material/BuildCircle' import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import ViewAgendaIcon from '@mui/icons-material/ViewAgenda' +import FolderZipIcon from '@mui/icons-material/FolderZip' import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material' -import { useRecoilState } from 'recoil' -import { listViewState } from '../atoms/settings' +import { useRecoilState, useRecoilValue } from 'recoil' +import { listViewState, serverURL } from '../atoms/settings' import { useI18n } from '../hooks/useI18n' import { useRPC } from '../hooks/useRPC' @@ -19,6 +20,7 @@ type Props = { } const HomeSpeedDial: React.FC = ({ onDownloadOpen, onEditorOpen }) => { + const serverAddr = useRecoilValue(serverURL) const [listView, setListView] = useRecoilState(listViewState) const { i18n } = useI18n() @@ -37,6 +39,11 @@ const HomeSpeedDial: React.FC = ({ onDownloadOpen, onEditorOpen }) => { tooltipTitle={listView ? 'Card view' : 'Table view'} onClick={() => setListView(state => !state)} /> + } + tooltipTitle={i18n.t('bulkDownload')} + onClick={() => window.open(`${serverAddr}/archive/bulk`)} + /> } tooltipTitle={i18n.t('abortAllButton')} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c0bb39a..2b263f4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -45,6 +45,9 @@ export type RPCResult = Readonly<{ id: string progress: DownloadProgress info: DownloadInfo + output: { + savedFilePath: string + } }> export type RPCParams = { diff --git a/server/handlers/archive.go b/server/handlers/archive.go index a6a4481..4ba2756 100644 --- a/server/handlers/archive.go +++ b/server/handlers/archive.go @@ -1,6 +1,8 @@ package handlers import ( + "archive/zip" + "bytes" "encoding/base64" "encoding/json" "io" @@ -8,12 +10,14 @@ import ( "net/url" "os" "path/filepath" + "slices" "sort" "strings" "time" "github.com/go-chi/chi/v5" "github.com/marcopeocchi/yt-dlp-web-ui/server/config" + "github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/utils" ) @@ -194,3 +198,50 @@ func DownloadFile(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) } + +func BulkDownload(mdb *internal.MemoryDB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + procs := slices.DeleteFunc(*mdb.All(), func(e internal.ProcessResponse) bool { + return e.Progress.Status != 2 // status completed + }) + + var ( + buff bytes.Buffer + zipWriter = zip.NewWriter(&buff) + ) + + for _, p := range procs { + wr, err := zipWriter.Create(filepath.Base(p.Output.SavedFilePath)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fd, err := os.Open(p.Output.SavedFilePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, err = io.Copy(wr, fd) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + err := zipWriter.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add( + "Content-Disposition", + "inline; filename=download-archive-"+time.Now().Format(time.RFC3339)+".zip", + ) + w.Header().Set("Content-Type", "application/zip") + + io.Copy(w, &buff) + } +} diff --git a/server/internal/process.go b/server/internal/process.go index 9d38a5a..ee75790 100644 --- a/server/internal/process.go +++ b/server/internal/process.go @@ -55,8 +55,9 @@ type Process struct { } type DownloadOutput struct { - Path string - Filename string + Path string + Filename string + SavedFilePath string `json:"savedFilePath"` } // Starts spawns/forks a new yt-dlp process and parse its stdout. @@ -91,6 +92,8 @@ func (p *Process) Start() { buildFilename(&p.Output) + go p.GetFileName(&out) + params := []string{ strings.Split(p.Url, "?list")[0], //no playlist "--newline", @@ -269,6 +272,24 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) { return info, nil } +func (p *Process) GetFileName(o *DownloadOutput) error { + cmd := exec.Command( + config.Instance().DownloaderPath, + "--print", "filename", + "-o", fmt.Sprintf("%s/%s", o.Path, o.Filename), + p.Url, + ) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + out, err := cmd.Output() + if err != nil { + return err + } + + p.Output.SavedFilePath = strings.Trim(string(out), "\n") + return nil +} + func (p *Process) SetPending() { // Since video's title isn't available yet, fill in with the URL. p.Info = DownloadInfo{ diff --git a/server/server.go b/server/server.go index 6307b2f..b10a974 100644 --- a/server/server.go +++ b/server/server.go @@ -163,6 +163,7 @@ func newServer(c serverConfig) *http.Server { r.Post("/delete", handlers.DeleteFile) r.Get("/d/{id}", handlers.DownloadFile) r.Get("/v/{id}", handlers.SendFile) + r.Get("/bulk", handlers.BulkDownload(c.mdb)) }) // Authentication routes