Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3edebbdb6c | |||
|
|
98f0ea3bd2 | ||
| 0daf36719b | |||
| 38683bfe85 | |||
| 4066a6d5e3 | |||
| ee18929196 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user