Implemented "download file" in dashboard and bulk download

closes #115
This commit is contained in:
2024-04-16 11:27:47 +02:00
parent 294ad29bf2
commit 205f2e5cdf
8 changed files with 174 additions and 14 deletions

View File

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

View File

@@ -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<Props> = ({ 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<Props> = ({ 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 (
<Card>
<CardActionArea onClick={() => {
@@ -109,6 +123,26 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
>
{isCompleted() ? "Clear" : "Stop"}
</Button>
{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>
</>
}
</CardActions>
</Card>
)

View File

@@ -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 (
<TableContainer
sx={{ minHeight: '80vh', mt: 4 }}
@@ -108,13 +121,31 @@ const DownloadsTableView: React.FC = () => {
{new Date(download.info.created_at).toLocaleString()}
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => abort(download.id)}
>
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
<ButtonGroup>
<IconButton
size="small"
onClick={() => abort(download.id)}
>
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}
</IconButton>
</IconButton>
{download.progress.percentage === '-1' &&
<>
<IconButton
size="small"
onClick={() => viewFile(download.output.savedFilePath)}
>
<SmartDisplayIcon />
</IconButton>
<IconButton
size="small"
onClick={() => downloadFile(download.output.savedFilePath)}
>
<FileDownloadIcon />
</IconButton>
</>
}
</ButtonGroup>
</TableCell>
</TableRow>
))

View File

@@ -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<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const serverAddr = useRecoilValue(serverURL)
const [listView, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n()
@@ -37,6 +39,11 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
tooltipTitle={listView ? 'Card view' : 'Table view'}
onClick={() => setListView(state => !state)}
/>
<SpeedDialAction
icon={<FolderZipIcon />}
tooltipTitle={i18n.t('bulkDownload')}
onClick={() => window.open(`${serverAddr}/archive/bulk`)}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}

View File

@@ -45,6 +45,9 @@ export type RPCResult = Readonly<{
id: string
progress: DownloadProgress
info: DownloadInfo
output: {
savedFilePath: string
}
}>
export type RPCParams = {

View File

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

View File

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

View File

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