10 feat download queue (#59)
* testing message queue * better mq syncronisation * major code refactoring, concern separation. * bugfix * code refactoring * queuesize configurable via flags * code refactoring * comments * code refactoring, updated readme
This commit is contained in:
36
README.md
36
README.md
@@ -68,6 +68,7 @@ The currently avaible settings are:
|
|||||||
- Override the output filename
|
- Override the output filename
|
||||||
- Override the output path
|
- Override the output path
|
||||||
- Pass custom yt-dlp arguments safely
|
- Pass custom yt-dlp arguments safely
|
||||||
|
- Download queue (limit concurrent downloads)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -84,8 +85,9 @@ Future releases will have:
|
|||||||
- ~~Multi download~~ *done*
|
- ~~Multi download~~ *done*
|
||||||
- ~~Exctract audio~~ *done*
|
- ~~Exctract audio~~ *done*
|
||||||
- ~~Format selection~~ *done*
|
- ~~Format selection~~ *done*
|
||||||
- Download archive
|
- ~~Download archive~~ *done*
|
||||||
- ~~ARM Build~~ *done available through ghcr.io*
|
- ~~ARM Build~~ *done available through ghcr.io*
|
||||||
|
- Playlist support
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- **It says that it isn't connected/ip in the header is not defined.**
|
- **It says that it isn't connected/ip in the header is not defined.**
|
||||||
@@ -118,6 +120,18 @@ docker run -d \
|
|||||||
--secret your_rpc_secret
|
--secret your_rpc_secret
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you wish for limiting the download queue size...
|
||||||
|
|
||||||
|
e.g. limiting max 2 concurrent download.
|
||||||
|
```sh
|
||||||
|
docker run -d \
|
||||||
|
-p 3033:3033 \
|
||||||
|
-e JWT_SECRET randomsecret
|
||||||
|
-v /path/to/downloads:/downloads \
|
||||||
|
marcobaobao/yt-dlp-webui \
|
||||||
|
--qs 2
|
||||||
|
```
|
||||||
|
|
||||||
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
|
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -134,6 +148,25 @@ yt-dlp-webui --out /home/user/downloads --driver /opt/soemdir/yt-dlp
|
|||||||
yt-dlp-webui --conf /home/user/.config/yt-dlp-webui.conf
|
yt-dlp-webui --conf /home/user/.config/yt-dlp-webui.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Arguments
|
||||||
|
```sh
|
||||||
|
Usage yt-dlp-webui:
|
||||||
|
-auth
|
||||||
|
Enable RPC authentication
|
||||||
|
-conf string
|
||||||
|
Config file path
|
||||||
|
-driver string
|
||||||
|
yt-dlp executable path (default "yt-dlp")
|
||||||
|
-out string
|
||||||
|
Where files will be saved (default ".")
|
||||||
|
-port int
|
||||||
|
Port where server will listen at (default 3033)
|
||||||
|
-qs int
|
||||||
|
Download queue size (default 8)
|
||||||
|
-secret string
|
||||||
|
Secret required for auth
|
||||||
|
```
|
||||||
|
|
||||||
### Config file
|
### Config file
|
||||||
By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file.
|
By running `yt-dlp-webui` in standalone mode you have the ability to also specify a config file.
|
||||||
The config file **will overwrite what have been passed as cli argument**.
|
The config file **will overwrite what have been passed as cli argument**.
|
||||||
@@ -149,6 +182,7 @@ downloaderPath: /usr/local/bin/yt-dlp
|
|||||||
# Optional settings
|
# Optional settings
|
||||||
require_auth: true
|
require_auth: true
|
||||||
rpc_secret: my_random_secret
|
rpc_secret: my_random_secret
|
||||||
|
queue_size: 4
|
||||||
```
|
```
|
||||||
|
|
||||||
### Systemd integration
|
### Systemd integration
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
|
|||||||
resolution={download.info.resolution ?? ''}
|
resolution={download.info.resolution ?? ''}
|
||||||
speed={download.progress.speed}
|
speed={download.progress.speed}
|
||||||
size={download.info.filesize_approx ?? 0}
|
size={download.info.filesize_approx ?? 0}
|
||||||
|
status={download.progress.process_status}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ export function DownloadsListView({ downloads, onStop }: Props) {
|
|||||||
download.progress.percentage === '-1' ? 100 :
|
download.progress.percentage === '-1' ? 100 :
|
||||||
Number(download.progress.percentage.replace('%', ''))
|
Number(download.progress.percentage.replace('%', ''))
|
||||||
}
|
}
|
||||||
variant="determinate"
|
variant={
|
||||||
|
download.progress.process_status === 0
|
||||||
|
? 'indeterminate'
|
||||||
|
: 'determinate'
|
||||||
|
}
|
||||||
color={download.progress.percentage === '-1' ? 'success' : 'primary'}
|
color={download.progress.percentage === '-1' ? 'success' : 'primary'}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ellipsis, formatSpeedMiB, roundMiB } from '../utils'
|
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string
|
title: string
|
||||||
@@ -23,6 +23,7 @@ type Props = {
|
|||||||
percentage: string
|
percentage: string
|
||||||
size: number
|
size: number
|
||||||
speed: number
|
speed: number
|
||||||
|
status: number
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,7 @@ export function StackableResult({
|
|||||||
percentage,
|
percentage,
|
||||||
speed,
|
speed,
|
||||||
size,
|
size,
|
||||||
|
status,
|
||||||
onStop,
|
onStop,
|
||||||
onCopy,
|
onCopy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -80,7 +82,7 @@ export function StackableResult({
|
|||||||
}
|
}
|
||||||
<Stack direction="row" spacing={1} py={2}>
|
<Stack direction="row" spacing={1} py={2}>
|
||||||
<Chip
|
<Chip
|
||||||
label={isCompleted ? 'Completed' : 'Downloading'}
|
label={isCompleted ? 'Completed' : mapProcessStatus(status)}
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|||||||
1
frontend/src/types/index.d.ts
vendored
1
frontend/src/types/index.d.ts
vendored
@@ -37,6 +37,7 @@ type DownloadProgress = {
|
|||||||
speed: number
|
speed: number
|
||||||
eta: number
|
eta: number
|
||||||
percentage: string
|
percentage: string
|
||||||
|
process_status: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RPCResult = {
|
export type RPCResult = {
|
||||||
|
|||||||
@@ -100,3 +100,18 @@ export const datetimeCompareFunc = (a: string, b: string) => new Date(a).getTime
|
|||||||
export function isRPCResponse(object: any): object is RPCResponse<any> {
|
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) {
|
||||||
|
switch (status) {
|
||||||
|
case 0:
|
||||||
|
return 'Pending'
|
||||||
|
case 1:
|
||||||
|
return 'Downloading'
|
||||||
|
case 2:
|
||||||
|
return 'Completed'
|
||||||
|
case 3:
|
||||||
|
return 'Error'
|
||||||
|
default:
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
7
main.go
7
main.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
@@ -12,20 +13,23 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
port int
|
port int
|
||||||
|
queueSize int
|
||||||
configFile string
|
configFile string
|
||||||
downloadPath string
|
downloadPath string
|
||||||
downloaderPath string
|
downloaderPath string
|
||||||
|
|
||||||
requireAuth bool
|
requireAuth bool
|
||||||
rpcSecret string
|
rpcSecret string
|
||||||
|
|
||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
frontend embed.FS
|
frontend embed.FS
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
||||||
|
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
|
||||||
|
|
||||||
flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path")
|
flag.StringVar(&configFile, "conf", "", "Config file path")
|
||||||
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
||||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ func main() {
|
|||||||
c := config.Instance()
|
c := config.Instance()
|
||||||
|
|
||||||
c.SetPort(port)
|
c.SetPort(port)
|
||||||
|
c.QueueSize(queueSize)
|
||||||
c.DownloadPath(downloadPath)
|
c.DownloadPath(downloadPath)
|
||||||
c.DownloaderPath(downloaderPath)
|
c.DownloaderPath(downloaderPath)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type serverConfig struct {
|
|||||||
DownloaderPath string `yaml:"downloaderPath"`
|
DownloaderPath string `yaml:"downloaderPath"`
|
||||||
RequireAuth bool `yaml:"require_auth"`
|
RequireAuth bool `yaml:"require_auth"`
|
||||||
RPCSecret string `yaml:"rpc_secret"`
|
RPCSecret string `yaml:"rpc_secret"`
|
||||||
|
QueueSize int `yaml:"queue_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -51,10 +52,15 @@ func (c *config) DownloaderPath(path string) {
|
|||||||
func (c *config) RequireAuth(value bool) {
|
func (c *config) RequireAuth(value bool) {
|
||||||
c.cfg.RequireAuth = value
|
c.cfg.RequireAuth = value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *config) RPCSecret(secret string) {
|
func (c *config) RPCSecret(secret string) {
|
||||||
c.cfg.RPCSecret = secret
|
c.cfg.RPCSecret = secret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *config) QueueSize(size int) {
|
||||||
|
c.cfg.QueueSize = size
|
||||||
|
}
|
||||||
|
|
||||||
var instance *config
|
var instance *config
|
||||||
|
|
||||||
func Instance() *config {
|
func Instance() *config {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -20,7 +20,7 @@ type MemoryDB struct {
|
|||||||
|
|
||||||
// Get a process pointer given its id
|
// Get a process pointer given its id
|
||||||
func (m *MemoryDB) Get(id string) (*Process, error) {
|
func (m *MemoryDB) Get(id string) (*Process, error) {
|
||||||
entry, ok := db.table.Load(id)
|
entry, ok := m.table.Load(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("no process found for the given key")
|
return nil, errors.New("no process found for the given key")
|
||||||
}
|
}
|
||||||
@@ -30,28 +30,32 @@ func (m *MemoryDB) Get(id string) (*Process, error) {
|
|||||||
// Store a pointer of a process and return its id
|
// Store a pointer of a process and return its id
|
||||||
func (m *MemoryDB) Set(process *Process) string {
|
func (m *MemoryDB) Set(process *Process) string {
|
||||||
id := uuid.Must(uuid.NewRandom()).String()
|
id := uuid.Must(uuid.NewRandom()).String()
|
||||||
db.table.Store(id, process)
|
m.table.Store(id, process)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update a process info/metadata, given the process id
|
// Update a process info/metadata, given the process id
|
||||||
|
//
|
||||||
|
// Deprecated: will be removed anytime soon.
|
||||||
func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
|
func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
|
||||||
entry, ok := db.table.Load(id)
|
entry, ok := m.table.Load(id)
|
||||||
if ok {
|
if ok {
|
||||||
entry.(*Process).Info = info
|
entry.(*Process).Info = info
|
||||||
db.table.Store(id, entry)
|
m.table.Store(id, entry)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("can't update row with id %s", id)
|
return fmt.Errorf("can't update row with id %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update a process progress data, given the process id
|
// Update a process progress data, given the process id
|
||||||
// Used for updating completition percentage or ETA
|
// Used for updating completition percentage or ETA.
|
||||||
|
//
|
||||||
|
// Deprecated: will be removed anytime soon.
|
||||||
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
|
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
|
||||||
entry, ok := db.table.Load(id)
|
entry, ok := m.table.Load(id)
|
||||||
if ok {
|
if ok {
|
||||||
entry.(*Process).Progress = progress
|
entry.(*Process).Progress = progress
|
||||||
db.table.Store(id, entry)
|
m.table.Store(id, entry)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("can't update row with id %s", id)
|
return fmt.Errorf("can't update row with id %s", id)
|
||||||
@@ -59,12 +63,12 @@ func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
|
|||||||
|
|
||||||
// Removes a process progress, given the process id
|
// Removes a process progress, given the process id
|
||||||
func (m *MemoryDB) Delete(id string) {
|
func (m *MemoryDB) Delete(id string) {
|
||||||
db.table.Delete(id)
|
m.table.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryDB) Keys() *[]string {
|
func (m *MemoryDB) Keys() *[]string {
|
||||||
running := []string{}
|
running := []string{}
|
||||||
db.table.Range(func(key, value any) bool {
|
m.table.Range(func(key, value any) bool {
|
||||||
running = append(running, key.(string))
|
running = append(running, key.(string))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -74,7 +78,7 @@ func (m *MemoryDB) Keys() *[]string {
|
|||||||
// Returns a slice of all currently stored processes progess
|
// Returns a slice of all currently stored processes progess
|
||||||
func (m *MemoryDB) All() *[]ProcessResponse {
|
func (m *MemoryDB) All() *[]ProcessResponse {
|
||||||
running := []ProcessResponse{}
|
running := []ProcessResponse{}
|
||||||
db.table.Range(func(key, value any) bool {
|
m.table.Range(func(key, value any) bool {
|
||||||
running = append(running, ProcessResponse{
|
running = append(running, ProcessResponse{
|
||||||
Id: key.(string),
|
Id: key.(string),
|
||||||
Info: value.(*Process).Info,
|
Info: value.(*Process).Info,
|
||||||
@@ -110,12 +114,12 @@ func (m *MemoryDB) Restore() {
|
|||||||
json.Unmarshal(feed, &session)
|
json.Unmarshal(feed, &session)
|
||||||
|
|
||||||
for _, proc := range session.Processes {
|
for _, proc := range session.Processes {
|
||||||
db.table.Store(proc.Id, &Process{
|
m.table.Store(proc.Id, &Process{
|
||||||
id: proc.Id,
|
Id: proc.Id,
|
||||||
url: proc.Info.URL,
|
Url: proc.Info.URL,
|
||||||
Info: proc.Info,
|
Info: proc.Info,
|
||||||
Progress: proc.Progress,
|
Progress: proc.Progress,
|
||||||
mem: m,
|
DB: m,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
46
server/internal/message_queue.go
Normal file
46
server/internal/message_queue.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageQueue struct {
|
||||||
|
ch chan *Process
|
||||||
|
consumerCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new message queue.
|
||||||
|
// By default it will be created with a size equals to nthe number of logical
|
||||||
|
// CPU cores.
|
||||||
|
// The queue size can be set via the qs flag.
|
||||||
|
func NewMessageQueue() *MessageQueue {
|
||||||
|
size := config.Instance().GetConfig().QueueSize
|
||||||
|
|
||||||
|
if size <= 0 {
|
||||||
|
log.Fatalln("invalid queue size")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MessageQueue{
|
||||||
|
ch: make(chan *Process, size),
|
||||||
|
consumerCh: make(chan struct{}, size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish a message to the queue and set the task to a peding state.
|
||||||
|
func (m *MessageQueue) Publish(p *Process) {
|
||||||
|
go p.SetPending()
|
||||||
|
m.ch <- p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the consumer listened which "subscribes" to the queue events.
|
||||||
|
func (m *MessageQueue) SetupConsumer() {
|
||||||
|
for msg := range m.ch {
|
||||||
|
m.consumerCh <- struct{}{}
|
||||||
|
go func(p *Process) {
|
||||||
|
p.Start()
|
||||||
|
<-m.consumerCh
|
||||||
|
}(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
|
|
||||||
"github.com/marcopeocchi/fazzoletti/slices"
|
"github.com/marcopeocchi/fazzoletti/slices"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const template = `download:
|
const template = `download:
|
||||||
@@ -30,6 +29,13 @@ var (
|
|||||||
cfg = config.Instance()
|
cfg = config.Instance()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending = iota
|
||||||
|
StatusDownloading
|
||||||
|
StatusCompleted
|
||||||
|
StatusErrored
|
||||||
|
)
|
||||||
|
|
||||||
type ProgressTemplate struct {
|
type ProgressTemplate struct {
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
Speed float32 `json:"speed"`
|
Speed float32 `json:"speed"`
|
||||||
@@ -39,54 +45,55 @@ type ProgressTemplate struct {
|
|||||||
|
|
||||||
// Process descriptor
|
// Process descriptor
|
||||||
type Process struct {
|
type Process struct {
|
||||||
id string
|
Id string
|
||||||
url string
|
Url string
|
||||||
params []string
|
Params []string
|
||||||
Info DownloadInfo
|
Info DownloadInfo
|
||||||
Progress DownloadProgress
|
Progress DownloadProgress
|
||||||
mem *MemoryDB
|
DB *MemoryDB
|
||||||
|
Output DownloadOutput
|
||||||
proc *os.Process
|
proc *os.Process
|
||||||
}
|
}
|
||||||
|
|
||||||
type downloadOutput struct {
|
type DownloadOutput struct {
|
||||||
path string
|
Path string
|
||||||
filaneme string
|
Filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts spawns/forks a new yt-dlp process and parse its stdout.
|
// Starts spawns/forks a new yt-dlp process and parse its stdout.
|
||||||
// The process is spawned to outputting a custom progress text that
|
// The process is spawned to outputting a custom progress text that
|
||||||
// Resembles a JSON Object in order to Unmarshal it later.
|
// Resembles a JSON Object in order to Unmarshal it later.
|
||||||
// This approach is anyhow not perfect: quotes are not escaped properly.
|
// This approach is anyhow not perfect: quotes are not escaped properly.
|
||||||
// Each process is not identified by its PID but by a UUIDv2
|
// Each process is not identified by its PID but by a UUIDv4
|
||||||
func (p *Process) Start(path, filename string) {
|
func (p *Process) Start() {
|
||||||
// escape bash variable escaping and command piping, you'll never know
|
// escape bash variable escaping and command piping, you'll never know
|
||||||
// what they might come with...
|
// what they might come with...
|
||||||
p.params = slices.Filter(p.params, func(e string) bool {
|
p.Params = slices.Filter(p.Params, func(e string) bool {
|
||||||
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
|
match, _ := regexp.MatchString(`(\$\{)|(\&\&)`, e)
|
||||||
return !match
|
return !match
|
||||||
})
|
})
|
||||||
|
|
||||||
out := downloadOutput{
|
out := DownloadOutput{
|
||||||
path: cfg.GetConfig().DownloadPath,
|
Path: cfg.GetConfig().DownloadPath,
|
||||||
filaneme: "%(title)s.%(ext)s",
|
Filename: "%(title)s.%(ext)s",
|
||||||
}
|
}
|
||||||
|
|
||||||
if path != "" {
|
if p.Output.Path != "" {
|
||||||
out.path = path
|
out.Path = p.Output.Path
|
||||||
}
|
}
|
||||||
if filename != "" {
|
if p.Output.Filename != "" {
|
||||||
out.filaneme = filename + ".%(ext)s"
|
out.Filename = p.Output.Filename + ".%(ext)s"
|
||||||
}
|
}
|
||||||
|
|
||||||
params := append([]string{
|
params := append([]string{
|
||||||
strings.Split(p.url, "?list")[0], //no playlist
|
strings.Split(p.Url, "?list")[0], //no playlist
|
||||||
"--newline",
|
"--newline",
|
||||||
"--no-colors",
|
"--no-colors",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--progress-template", strings.ReplaceAll(template, "\n", ""),
|
"--progress-template", strings.ReplaceAll(template, "\n", ""),
|
||||||
"-o",
|
"-o",
|
||||||
fmt.Sprintf("%s/%s", out.path, out.filaneme),
|
fmt.Sprintf("%s/%s", out.Path, out.Filename),
|
||||||
}, p.params...)
|
}, p.Params...)
|
||||||
|
|
||||||
// ----------------- main block ----------------- //
|
// ----------------- main block ----------------- //
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, params...)
|
cmd := exec.Command(cfg.GetConfig().DownloaderPath, params...)
|
||||||
@@ -103,73 +110,56 @@ func (p *Process) Start(path, filename string) {
|
|||||||
log.Panicln(err)
|
log.Panicln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.id = p.mem.Set(p)
|
|
||||||
p.proc = cmd.Process
|
p.proc = cmd.Process
|
||||||
|
|
||||||
// ----------------- info block ----------------- //
|
// ----------------- info block ----------------- //
|
||||||
// spawn a goroutine that retrieves the info for the download
|
// spawn a goroutine that retrieves the info for the download
|
||||||
go func() {
|
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
|
|
||||||
stdout, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Cannot retrieve info for", p.url)
|
|
||||||
}
|
|
||||||
info := DownloadInfo{
|
|
||||||
URL: p.url,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
json.Unmarshal(stdout, &info)
|
|
||||||
p.mem.UpdateInfo(p.id, info)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// --------------- progress block --------------- //
|
// --------------- progress block --------------- //
|
||||||
// unbuffered channel connected to stdout
|
// unbuffered channel connected to stdout
|
||||||
eventChan := make(chan string)
|
|
||||||
|
|
||||||
// spawn a goroutine that does the dirty job of parsing the stdout
|
// spawn a goroutine that does the dirty job of parsing the stdout
|
||||||
// filling the channel with as many stdout line as yt-dlp produces (producer)
|
// filling the channel with as many stdout line as yt-dlp produces (producer)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.Close()
|
defer func() {
|
||||||
defer p.Complete()
|
r.Close()
|
||||||
for scan.Scan() {
|
p.Complete()
|
||||||
eventChan <- scan.Text()
|
|
||||||
}
|
|
||||||
cmd.Wait()
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// do the unmarshal operation every 250ms (consumer)
|
for scan.Scan() {
|
||||||
go rx.Debounce(time.Millisecond*250, eventChan, func(text string) {
|
|
||||||
stdout := ProgressTemplate{}
|
stdout := ProgressTemplate{}
|
||||||
err := json.Unmarshal([]byte(text), &stdout)
|
err := json.Unmarshal([]byte(scan.Text()), &stdout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
p.mem.UpdateProgress(p.id, DownloadProgress{
|
p.Progress = DownloadProgress{
|
||||||
|
Status: StatusDownloading,
|
||||||
Percentage: stdout.Percentage,
|
Percentage: stdout.Percentage,
|
||||||
Speed: stdout.Speed,
|
Speed: stdout.Speed,
|
||||||
ETA: stdout.Eta,
|
ETA: stdout.Eta,
|
||||||
})
|
|
||||||
shortId := strings.Split(p.id, "-")[0]
|
|
||||||
log.Printf("[%s] %s %s\n", shortId, p.url, p.Progress.Percentage)
|
|
||||||
}
|
}
|
||||||
})
|
shortId := strings.Split(p.Id, "-")[0]
|
||||||
|
log.Printf("[%s] %s %s\n", shortId, p.Url, p.Progress.Percentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// ------------- end progress block ------------- //
|
// ------------- end progress block ------------- //
|
||||||
|
cmd.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep process in the memoryDB but marks it as complete
|
// Keep process in the memoryDB but marks it as complete
|
||||||
// Convention: All completed processes has progress -1
|
// Convention: All completed processes has progress -1
|
||||||
// and speed 0 bps.
|
// and speed 0 bps.
|
||||||
func (p *Process) Complete() {
|
func (p *Process) Complete() {
|
||||||
p.mem.UpdateProgress(p.id, DownloadProgress{
|
p.Progress = DownloadProgress{
|
||||||
|
Status: StatusCompleted,
|
||||||
Percentage: "-1",
|
Percentage: "-1",
|
||||||
Speed: 0,
|
Speed: 0,
|
||||||
ETA: 0,
|
ETA: 0,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
p.mem.Delete(p.id)
|
|
||||||
// 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
|
||||||
@@ -181,15 +171,17 @@ func (p *Process) Kill() error {
|
|||||||
}
|
}
|
||||||
err = syscall.Kill(-pgid, syscall.SIGTERM)
|
err = syscall.Kill(-pgid, syscall.SIGTERM)
|
||||||
|
|
||||||
log.Println("Killed process", p.id)
|
log.Println("Killed process", p.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.DB.Delete(p.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the available format for this URL
|
// Returns the available format for this URL
|
||||||
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.url, "-J")
|
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.Url, "-J")
|
||||||
stdout, err := cmd.Output()
|
stdout, err := cmd.Output()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -198,7 +190,7 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
|||||||
|
|
||||||
cmd.Wait()
|
cmd.Wait()
|
||||||
|
|
||||||
info := DownloadFormats{URL: p.url}
|
info := DownloadFormats{URL: p.Url}
|
||||||
best := Format{}
|
best := Format{}
|
||||||
|
|
||||||
json.Unmarshal(stdout, &info)
|
json.Unmarshal(stdout, &info)
|
||||||
@@ -208,3 +200,25 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
|||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Process) SetPending() {
|
||||||
|
p.Id = p.DB.Set(p)
|
||||||
|
|
||||||
|
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.Url, "-J")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Cannot retrieve info for", p.Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := DownloadInfo{
|
||||||
|
URL: p.Url,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal(stdout, &info)
|
||||||
|
p.Info = info
|
||||||
|
|
||||||
|
p.Progress.Status = StatusPending
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package server
|
package internal
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Progress for the Running call
|
// Progress for the Running call
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
|
Status int `json:"process_status"`
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
Speed float32 `json:"speed"`
|
Speed float32 `json:"speed"`
|
||||||
ETA int `json:"eta"`
|
ETA int `json:"eta"`
|
||||||
@@ -66,4 +67,8 @@ type DownloadRequest struct {
|
|||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Params []string `json:"params"`
|
Params []string `json:"params"`
|
||||||
RenameTo string `json:"renameTo"`
|
RenameTo string `json:"renameTo"`
|
||||||
|
Id string
|
||||||
|
URL string
|
||||||
|
Path string
|
||||||
|
Rename string
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
package server
|
package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service int
|
type Service struct {
|
||||||
|
db *internal.MemoryDB
|
||||||
|
mq *internal.MessageQueue
|
||||||
|
}
|
||||||
|
|
||||||
type Running []ProcessResponse
|
type Running []internal.ProcessResponse
|
||||||
type Pending []string
|
type Pending []string
|
||||||
|
|
||||||
type NoArgs struct{}
|
type NoArgs struct{}
|
||||||
@@ -28,19 +32,38 @@ type DownloadSpecificArgs struct {
|
|||||||
Params []string
|
Params []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dependency injection container.
|
||||||
|
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
mq: mq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exec spawns a Process.
|
// Exec spawns a Process.
|
||||||
// The result of the execution is the newly spawned process Id.
|
// The result of the execution is the newly spawned process Id.
|
||||||
func (t *Service) Exec(args DownloadSpecificArgs, result *string) error {
|
func (s *Service) Exec(args DownloadSpecificArgs, result *string) error {
|
||||||
log.Println("Spawning new process for", args.URL)
|
log.Println("Sending new process to message queue", args.URL)
|
||||||
p := Process{mem: &db, url: args.URL, params: args.Params}
|
|
||||||
p.Start(args.Path, args.Rename)
|
p := &internal.Process{
|
||||||
*result = p.id
|
DB: s.db,
|
||||||
|
Url: args.URL,
|
||||||
|
Params: args.Params,
|
||||||
|
Output: internal.DownloadOutput{
|
||||||
|
Path: args.Path,
|
||||||
|
Filename: args.Rename,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mq.Publish(p)
|
||||||
|
*result = p.Id
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progess retrieves the Progress of a specific Process given its Id
|
// Progess retrieves the Progress of a specific Process given its Id
|
||||||
func (t *Service) Progess(args Args, progress *DownloadProgress) error {
|
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error {
|
||||||
proc, err := db.Get(args.Id)
|
proc, err := s.db.Get(args.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -49,29 +72,29 @@ func (t *Service) Progess(args Args, progress *DownloadProgress) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Progess retrieves the Progress of a specific Process given its Id
|
// Progess retrieves the Progress of a specific Process given its Id
|
||||||
func (t *Service) Formats(args Args, progress *DownloadFormats) error {
|
func (s *Service) Formats(args Args, progress *internal.DownloadFormats) error {
|
||||||
var err error
|
var err error
|
||||||
p := Process{url: args.URL}
|
p := internal.Process{Url: args.URL}
|
||||||
*progress, err = p.GetFormatsSync()
|
*progress, err = p.GetFormatsSync()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending retrieves a slice of all Pending/Running processes ids
|
// Pending retrieves a slice of all Pending/Running processes ids
|
||||||
func (t *Service) Pending(args NoArgs, pending *Pending) error {
|
func (s *Service) Pending(args NoArgs, pending *Pending) error {
|
||||||
*pending = *db.Keys()
|
*pending = *s.db.Keys()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Running retrieves a slice of all Processes progress
|
// Running retrieves a slice of all Processes progress
|
||||||
func (t *Service) Running(args NoArgs, running *Running) error {
|
func (s *Service) Running(args NoArgs, running *Running) error {
|
||||||
*running = *db.All()
|
*running = *s.db.All()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill kills a process given its id and remove it from the memoryDB
|
// Kill kills a process given its id and remove it from the memoryDB
|
||||||
func (t *Service) Kill(args string, killed *string) error {
|
func (s *Service) Kill(args string, killed *string) error {
|
||||||
log.Println("Trying killing process with id", args)
|
log.Println("Trying killing process with id", args)
|
||||||
proc, err := db.Get(args)
|
proc, err := s.db.Get(args)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -80,18 +103,18 @@ func (t *Service) Kill(args string, killed *string) error {
|
|||||||
err = proc.Kill()
|
err = proc.Kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Delete(proc.id)
|
s.db.Delete(proc.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// KillAll kills all process unconditionally and removes them from
|
// KillAll kills all process unconditionally and removes them from
|
||||||
// the memory db
|
// the memory db
|
||||||
func (t *Service) KillAll(args NoArgs, killed *string) error {
|
func (s *Service) KillAll(args NoArgs, killed *string) error {
|
||||||
log.Println("Killing all spawned processes", args)
|
log.Println("Killing all spawned processes", args)
|
||||||
keys := db.Keys()
|
keys := s.db.Keys()
|
||||||
var err error
|
var err error
|
||||||
for _, key := range *keys {
|
for _, key := range *keys {
|
||||||
proc, err := db.Get(key)
|
proc, err := s.db.Get(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -103,28 +126,28 @@ func (t *Service) KillAll(args NoArgs, killed *string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove a process from the db rendering it unusable if active
|
// Remove a process from the db rendering it unusable if active
|
||||||
func (t *Service) Clear(args string, killed *string) error {
|
func (s *Service) Clear(args string, killed *string) error {
|
||||||
log.Println("Clearing process with id", args)
|
log.Println("Clearing process with id", args)
|
||||||
db.Delete(args)
|
s.db.Delete(args)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreeSpace gets the available from package sys util
|
// FreeSpace gets the available from package sys util
|
||||||
func (t *Service) FreeSpace(args NoArgs, free *uint64) error {
|
func (s *Service) FreeSpace(args NoArgs, free *uint64) error {
|
||||||
freeSpace, err := sys.FreeSpace()
|
freeSpace, err := sys.FreeSpace()
|
||||||
*free = freeSpace
|
*free = freeSpace
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a flattned tree of the download directory
|
// Return a flattned tree of the download directory
|
||||||
func (t *Service) DirectoryTree(args NoArgs, tree *[]string) error {
|
func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
|
||||||
dfsTree, err := sys.DirectoryTree()
|
dfsTree, err := sys.DirectoryTree()
|
||||||
*tree = *dfsTree
|
*tree = *dfsTree
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates the yt-dlp binary using its builtin function
|
// Updates the yt-dlp binary using its builtin function
|
||||||
func (t *Service) UpdateExecutable(args NoArgs, updated *bool) error {
|
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
|
||||||
log.Println("Updating yt-dlp executable to the latest release")
|
log.Println("Updating yt-dlp executable to the latest release")
|
||||||
err := updater.UpdateExecutable()
|
err := updater.UpdateExecutable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -17,16 +17,21 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
"github.com/gofiber/websocket/v2"
|
"github.com/gofiber/websocket/v2"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||||
|
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
var db MemoryDB
|
|
||||||
|
|
||||||
func RunBlocking(port int, frontend fs.FS) {
|
func RunBlocking(port int, frontend fs.FS) {
|
||||||
|
var db internal.MemoryDB
|
||||||
db.Restore()
|
db.Restore()
|
||||||
|
|
||||||
service := new(Service)
|
mq := internal.NewMessageQueue()
|
||||||
|
go mq.SetupConsumer()
|
||||||
|
|
||||||
|
service := ytdlpRPC.Container(&db, mq)
|
||||||
|
|
||||||
rpc.Register(service)
|
rpc.Register(service)
|
||||||
|
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -93,13 +98,13 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
|
|
||||||
app.Server().StreamRequestBody = true
|
app.Server().StreamRequestBody = true
|
||||||
|
|
||||||
go gracefulShutdown(app)
|
go gracefulShutdown(app, &db)
|
||||||
go autoPersist(time.Minute * 5)
|
go autoPersist(time.Minute*5, &db)
|
||||||
|
|
||||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gracefulShutdown(app *fiber.App) {
|
func gracefulShutdown(app *fiber.App, db *internal.MemoryDB) {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(),
|
ctx, stop := signal.NotifyContext(context.Background(),
|
||||||
os.Interrupt,
|
os.Interrupt,
|
||||||
syscall.SIGTERM,
|
syscall.SIGTERM,
|
||||||
@@ -118,7 +123,7 @@ func gracefulShutdown(app *fiber.App) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func autoPersist(d time.Duration) {
|
func autoPersist(d time.Duration, db *internal.MemoryDB) {
|
||||||
for {
|
for {
|
||||||
db.Persist()
|
db.Persist()
|
||||||
time.Sleep(d)
|
time.Sleep(d)
|
||||||
|
|||||||
Reference in New Issue
Block a user