Compare commits

..

6 Commits

Author SHA1 Message Date
3edebbdb6c prevent completed download restart 2024-07-02 11:37:06 +02:00
Marco Piovanello
98f0ea3bd2 fix typo
closes #162
2024-06-30 10:37:22 +02:00
0daf36719b Fixed process not cleared after download.
Closes  #160
2024-06-21 10:41:49 +02:00
38683bfe85 code refactoring 2024-06-14 11:14:39 +02:00
4066a6d5e3 frontend code refactoring 2024-06-14 10:42:25 +02:00
ee18929196 missing psmisc docker file 2024-06-14 10:41:59 +02:00
17 changed files with 99 additions and 54 deletions

View File

@@ -28,7 +28,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
FROM alpine:edge FROM alpine:edge
RUN apk update && \ RUN apk update && \
apk add ffmpeg yt-dlp ca-certificates curl wget apk add ffmpeg yt-dlp ca-certificates curl wget psmisc
VOLUME /downloads /config VOLUME /downloads /config

View File

@@ -5,6 +5,7 @@ services:
- 3033:3033 - 3033:3033
volumes: volumes:
- <your dir>:/downloads # replace <your dir> with a directory on your host system - <your dir>:/downloads # replace <your dir> with a directory on your host system
- <your dir>:/config # directory where config.yml will be stored
healthcheck: healthcheck:
test: curl -f http://localhost:3033 || exit 1 test: curl -f http://localhost:3033 || exit 1
restart: unless-stopped restart: unless-stopped

View File

@@ -1,6 +1,6 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "3.0.9", "version": "3.1.0",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -3,7 +3,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'
import { loadingDownloadsState } from '../atoms/downloads' import { loadingDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings' import { listViewState } from '../atoms/settings'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import DownloadsCardView from './DownloadsCardView' import DownloadsGridView from './DownloadsGridView'
import DownloadsTableView from './DownloadsTableView' import DownloadsTableView from './DownloadsTableView'
const Downloads: React.FC = () => { const Downloads: React.FC = () => {
@@ -21,7 +21,7 @@ const Downloads: React.FC = () => {
if (tableView) return <DownloadsTableView /> if (tableView) return <DownloadsTableView />
return <DownloadsCardView /> return <DownloadsGridView />
} }
export default Downloads export default Downloads

View File

@@ -4,16 +4,19 @@ import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import DownloadCard from './DownloadCard' import DownloadCard from './DownloadCard'
const DownloadsCardView: React.FC = () => { const DownloadsGridView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) const downloads = useRecoilValue(activeDownloadsState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
const { pushMessage } = useToast() const { pushMessage } = useToast()
const abort = (id: string) => client.kill(id) const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.Completed
? client.clear(r.id)
: client.kill(r.id)
return ( return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}> <Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
@@ -22,7 +25,7 @@ const DownloadsCardView: React.FC = () => {
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}> <Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
<DownloadCard <DownloadCard
download={download} download={download}
onStop={() => abort(download.id)} onStop={() => stop(download)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')} onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/> />
</Grid> </Grid>
@@ -32,4 +35,4 @@ const DownloadsCardView: React.FC = () => {
) )
} }
export default DownloadsCardView export default DownloadsGridView

View File

@@ -24,7 +24,7 @@ import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { RPCResult } from '../types' import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils" import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
const columns = [ const columns = [
@@ -123,8 +123,6 @@ const DownloadsTableView: React.FC = () => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useRecoilValue(serverURL)
const { client } = useRPC() const { client } = useRPC()
const abort = (id: string) => client.kill(id)
const viewFile = (path: string) => { const viewFile = (path: string) => {
const encoded = base64URLEncode(path) const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`) window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
@@ -135,6 +133,11 @@ const DownloadsTableView: React.FC = () => {
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`) window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
} }
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.Completed
? client.clear(r.id)
: client.kill(r.id)
function rowContent(_index: number, download: RPCResult) { function rowContent(_index: number, download: RPCResult) {
return ( return (
<> <>
@@ -170,7 +173,7 @@ const DownloadsTableView: React.FC = () => {
<ButtonGroup> <ButtonGroup>
<IconButton <IconButton
size="small" size="small"
onClick={() => abort(download.id)} onClick={() => stop(download)}
> >
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />} {download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}

View File

@@ -35,7 +35,7 @@ const Footer: React.FC = () => {
display: 'flex', gap: 1, justifyContent: 'space-between' display: 'flex', gap: 1, justifyContent: 'space-between'
}}> }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Chip label="RPC v3.0.9" variant="outlined" size="small" /> <Chip label="RPC v3.1.0" variant="outlined" size="small" />
<VersionIndicator /> <VersionIndicator />
</div> </div>
<div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}> <div style={{ display: 'flex', gap: 4, 'alignItems': 'center' }}>

View File

@@ -26,8 +26,6 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
const abort = () => client.killAll()
return ( return (
<SpeedDial <SpeedDial
ariaLabel="Home speed dial" ariaLabel="Home speed dial"
@@ -47,7 +45,7 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
<SpeedDialAction <SpeedDialAction
icon={<DeleteForeverIcon />} icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')} tooltipTitle={i18n.t('abortAllButton')}
onClick={abort} onClick={() => client.killAll()}
/> />
<SpeedDialAction <SpeedDialAction
icon={<BuildCircleIcon />} icon={<BuildCircleIcon />}

View File

@@ -132,6 +132,13 @@ export class RPCClient {
}) })
} }
public clear(id: string) {
this.sendHTTP({
method: 'Service.Clear',
params: [id],
})
}
public killAll() { public killAll() {
this.sendHTTP({ this.sendHTTP({
method: 'Service.KillAll', method: 'Service.KillAll',

View File

@@ -34,11 +34,18 @@ type DownloadInfo = {
created_at: string created_at: string
} }
export enum ProcessStatus {
Pending = 0,
Downloading,
Completed,
Errored,
}
type DownloadProgress = { type DownloadProgress = {
speed: number speed: number
eta: number eta: number
percentage: string percentage: string
process_status: number process_status: ProcessStatus
} }
export type RPCResult = Readonly<{ export type RPCResult = Readonly<{
@@ -76,15 +83,14 @@ export type DirectoryEntry = {
name: string name: string
path: string path: string
size: number size: number
shaSum: string
modTime: string modTime: string
isVideo: boolean isVideo: boolean
isDirectory: boolean isDirectory: boolean
} }
export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'> export type DeleteRequest = Pick<DirectoryEntry, 'path'>
export type PlayRequest = Pick<DirectoryEntry, 'path'> export type PlayRequest = DeleteRequest
export type CustomTemplate = { export type CustomTemplate = {
id: string id: string

View File

@@ -1,5 +1,6 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import type { RPCResponse } from "./types" import type { RPCResponse } from "./types"
import { ProcessStatus } from './types'
export function validateIP(ipAddr: string): boolean { export function validateIP(ipAddr: string): boolean {
let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm
@@ -53,15 +54,15 @@ export function isRPCResponse(object: any): object is RPCResponse<any> {
return 'result' in object && 'id' in object return 'result' in object && 'id' in object
} }
export function mapProcessStatus(status: number) { export function mapProcessStatus(status: ProcessStatus) {
switch (status) { switch (status) {
case 0: case ProcessStatus.Pending:
return 'Pending' return 'Pending'
case 1: case ProcessStatus.Downloading:
return 'Downloading' return 'Downloading'
case 2: case ProcessStatus.Completed:
return 'Completed' return 'Completed'
case 3: case ProcessStatus.Errored:
return 'Error' return 'Error'
default: default:
return 'Pending' return 'Pending'

View File

@@ -113,7 +113,6 @@ export default function Downloaded() {
modTime: '', modTime: '',
name: '..', name: '..',
path: upperLevel, path: upperLevel,
shaSum: '',
size: 0, size: 0,
}, ...r.filter(f => f.name !== '')] }, ...r.filter(f => f.name !== '')]
: r.filter(f => f.name !== '') : r.filter(f => f.name !== '')
@@ -144,7 +143,6 @@ export default function Downloaded() {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
path: entry.path, path: entry.path,
shaSum: entry.shaSum,
}) })
}), }),
matchW( matchW(

View File

@@ -254,7 +254,7 @@ export default function Settings() {
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}> <Typography variant="h6" color="primary" sx={{ mt: 0.5, mb: 2 }}>
Appaerance Appearance
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>

View File

@@ -65,7 +65,9 @@ func (m *MessageQueue) downloadConsumer() {
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
p.Start() if p.Progress.Status != StatusCompleted {
p.Start()
}
m.logger.Info("started process", m.logger.Info("started process",
slog.String("bus", queueName), slog.String("bus", queueName),
@@ -92,6 +94,14 @@ func (m *MessageQueue) metadataSubscriber() {
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),
) )
if p.Progress.Status == StatusCompleted {
m.logger.Warn("proccess has an illegal state",
slog.String("id", p.getShortId()),
slog.Int("status", p.Progress.Status),
)
return
}
if err := p.SetMetadata(); err != nil { if err := p.SetMetadata(); err != nil {
m.logger.Error("failed to retrieve metadata", m.logger.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()), slog.String("id", p.getShortId()),

View File

@@ -198,18 +198,22 @@ func (p *Process) Complete() {
// Kill a process and remove it from the memory // Kill a process and remove it from the memory
func (p *Process) Kill() error { func (p *Process) Kill() error {
defer func() {
p.Progress.Status = StatusCompleted
}()
// yt-dlp uses multiple child process the parent process // yt-dlp uses multiple child process the parent process
// has been spawned with setPgid = true. To properly kill // has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct // all subprocesses a SIGTERM need to be sent to the correct
// process group // process group
if p.proc != nil { if p.proc == nil {
pgid, err := syscall.Getpgid(p.proc.Pid) return errors.New("*os.Process not set")
if err != nil { }
return err
}
err = syscall.Kill(-pgid, syscall.SIGTERM)
p.Logger.Info("killed process", slog.String("id", p.Id)) pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil {
return err
}
if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil {
return err return err
} }
@@ -223,10 +227,7 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
stdout, err := cmd.Output() stdout, err := cmd.Output()
if err != nil { if err != nil {
p.Logger.Error( p.Logger.Error("failed to retrieve metadata", slog.String("err", err.Error()))
"failed to retrieve metadata",
slog.String("err", err.Error()),
)
return DownloadFormats{}, err return DownloadFormats{}, err
} }

View File

@@ -2,7 +2,6 @@ package rpc
import ( import (
"io" "io"
"log"
"net/http" "net/http"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -30,8 +29,6 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
for { for {
mtype, reader, err := c.NextReader() mtype, reader, err := c.NextReader()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
break break
} }
@@ -40,7 +37,6 @@ func WebSocket(w http.ResponseWriter, r *http.Request) {
writer, err := c.NextWriter(mtype) writer, err := c.NextWriter(mtype)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
break break
} }

View File

@@ -1,6 +1,7 @@
package rpc package rpc
import ( import (
"errors"
"log/slog" "log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
@@ -99,13 +100,19 @@ func (s *Service) Kill(args string, killed *string) error {
return err return err
} }
if proc != nil { if proc == nil {
err = proc.Kill() return errors.New("nil process")
s.db.Delete(proc.Id) }
if err := proc.Kill(); err != nil {
s.logger.Info("failed killing process", slog.String("id", proc.Id), slog.Any("err", err))
return err
} }
s.db.Delete(proc.Id) s.db.Delete(proc.Id)
return err s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
return nil
} }
// KillAll kills all process unconditionally and removes them from // KillAll kills all process unconditionally and removes them from
@@ -114,8 +121,11 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
s.logger.Info("Killing all spawned processes") s.logger.Info("Killing all spawned processes")
var ( var (
keys = s.db.Keys() keys = s.db.Keys()
err error removeFunc = func(p *internal.Process) error {
defer s.db.Delete(p.Id)
return p.Kill()
}
) )
for _, key := range *keys { for _, key := range *keys {
@@ -124,13 +134,24 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
return err return err
} }
if proc != nil { if proc == nil {
proc.Kill() s.db.Delete(key)
s.db.Delete(proc.Id) continue
} }
if err := removeFunc(proc); err != nil {
s.logger.Info(
"failed killing process",
slog.String("id", proc.Id),
slog.Any("err", err),
)
continue
}
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
} }
return err return nil
} }
// Remove a process from the db rendering it unusable if active // Remove a process from the db rendering it unusable if active