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

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,19 @@ 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'
const DownloadsCardView: React.FC = () => {
const DownloadsGridView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState)
const { i18n } = useI18n()
const { client } = useRPC()
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 (
<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}>
<DownloadCard
download={download}
onStop={() => abort(download.id)}
onStop={() => stop(download)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</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 { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { RPCResult } from '../types'
import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
const columns = [
@@ -123,8 +123,6 @@ const DownloadsTableView: React.FC = () => {
const serverAddr = useRecoilValue(serverURL)
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')}`)
@@ -135,6 +133,11 @@ const DownloadsTableView: React.FC = () => {
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) {
return (
<>
@@ -170,7 +173,7 @@ const DownloadsTableView: React.FC = () => {
<ButtonGroup>
<IconButton
size="small"
onClick={() => abort(download.id)}
onClick={() => stop(download)}
>
{download.progress.percentage === '-1' ? <DeleteIcon /> : <StopCircleIcon />}

View File

@@ -35,7 +35,7 @@ const Footer: React.FC = () => {
display: 'flex', gap: 1, justifyContent: 'space-between'
}}>
<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 />
</div>
<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 { client } = useRPC()
const abort = () => client.killAll()
return (
<SpeedDial
ariaLabel="Home speed dial"
@@ -47,7 +45,7 @@ const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={abort}
onClick={() => client.killAll()}
/>
<SpeedDialAction
icon={<BuildCircleIcon />}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { pipe } from 'fp-ts/lib/function'
import type { RPCResponse } from "./types"
import { ProcessStatus } from './types'
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
@@ -53,15 +54,15 @@ export function isRPCResponse(object: any): object is RPCResponse<any> {
return 'result' in object && 'id' in object
}
export function mapProcessStatus(status: number) {
export function mapProcessStatus(status: ProcessStatus) {
switch (status) {
case 0:
case ProcessStatus.Pending:
return 'Pending'
case 1:
case ProcessStatus.Downloading:
return 'Downloading'
case 2:
case ProcessStatus.Completed:
return 'Completed'
case 3:
case ProcessStatus.Errored:
return 'Error'
default:
return 'Pending'

View File

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

View File

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

View File

@@ -65,7 +65,9 @@ func (m *MessageQueue) downloadConsumer() {
slog.String("id", p.getShortId()),
)
p.Start()
if p.Progress.Status != StatusCompleted {
p.Start()
}
m.logger.Info("started process",
slog.String("bus", queueName),
@@ -92,6 +94,14 @@ func (m *MessageQueue) metadataSubscriber() {
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 {
m.logger.Error("failed to retrieve metadata",
slog.String("id", p.getShortId()),

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package rpc
import (
"errors"
"log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
@@ -99,13 +100,19 @@ func (s *Service) Kill(args string, killed *string) error {
return err
}
if proc != nil {
err = proc.Kill()
s.db.Delete(proc.Id)
if proc == nil {
return errors.New("nil process")
}
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)
return err
s.logger.Info("succesfully killed process", slog.String("id", proc.Id))
return nil
}
// 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")
var (
keys = s.db.Keys()
err error
keys = s.db.Keys()
removeFunc = func(p *internal.Process) error {
defer s.db.Delete(p.Id)
return p.Kill()
}
)
for _, key := range *keys {
@@ -124,13 +134,24 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
return err
}
if proc != nil {
proc.Kill()
s.db.Delete(proc.Id)
if proc == nil {
s.db.Delete(key)
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