diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8e3bbb..5290988 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,8 +16,6 @@ import { Typography } from "@mui/material"; import { grey } from "@mui/material/colors"; - - import ListItemButton from '@mui/material/ListItemButton'; import { useEffect, useMemo, useState } from "react"; import { Provider, useSelector } from "react-redux"; diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..838113d --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/marcopeocchi/yt-dlp-web-ui + +go 1.19 + +require ( + github.com/gofiber/fiber/v2 v2.41.0 + github.com/gofiber/websocket/v2 v2.1.2 +) + +require ( + github.com/andybalholm/brotli v1.0.4 // indirect + github.com/fasthttp/websocket v1.5.0 // indirect + github.com/goccy/go-json v0.10.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/klauspost/compress v1.15.14 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/rivo/uniseg v0.4.3 // indirect + github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.43.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..83c2916 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/fasthttp/websocket v1.5.0 h1:B4zbe3xXyvIdnqjOZrafVFklCUq5ZLo/TqCt5JA1wLE= +github.com/fasthttp/websocket v1.5.0/go.mod h1:n0BlOQvJdPbTuBkZT0O5+jk/sp/1/VCzquR1BehI2F4= +github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= +github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gofiber/fiber/v2 v2.40.1/go.mod h1:Gko04sLksnHbzLSRBFWPFdzM9Ws9pRxvvIaohJK1dsk= +github.com/gofiber/fiber/v2 v2.41.0 h1:YhNoUS/OTjEz+/WLYuQ01xI7RXgKEFnGBKMagAu5f0M= +github.com/gofiber/fiber/v2 v2.41.0/go.mod h1:RdebcCuCRFp4W6hr3968/XxwJVg0K+jr9/Ae0PFzZ0Q= +github.com/gofiber/websocket/v2 v2.1.2 h1:EulKyLB/fJgui5+6c8irwEnYQ9FRsrLZfkrq9OfTDGc= +github.com/gofiber/websocket/v2 v2.1.2/go.mod h1:S+sKWo0xeC7Wnz5h4/8f6D/NxsrLFIdWDYB3SyVO9pE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc= +github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas= +github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo= +github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4= +github.com/valyala/fasthttp v1.41.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= +github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g= +github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..6de05a8 --- /dev/null +++ b/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "embed" + "io/fs" + "log" + "os" + + "github.com/marcopeocchi/yt-dlp-web-ui/server" +) + +type ContextKey interface{} + +var ( + port = os.Getenv("PORT") + //go:embed dist/frontend + frontend embed.FS +) + +func init() { + if port == "" { + port = "3033" + } +} + +func main() { + frontend, err := fs.Sub(frontend, "dist/frontend") + + if err != nil { + log.Fatalln(err) + } + + ctx := context.Background() + ctx = context.WithValue(ctx, ContextKey("port"), port) + ctx = context.WithValue(ctx, ContextKey("frontend"), frontend) + + server.RunBlocking(ctx) +} diff --git a/server/src/core/HTTPServer.ts b/server-node/src/core/HTTPServer.ts similarity index 100% rename from server/src/core/HTTPServer.ts rename to server-node/src/core/HTTPServer.ts diff --git a/server/src/core/Process.ts b/server-node/src/core/Process.ts similarity index 100% rename from server/src/core/Process.ts rename to server-node/src/core/Process.ts diff --git a/server/src/core/downloadArchive.ts b/server-node/src/core/downloadArchive.ts similarity index 100% rename from server/src/core/downloadArchive.ts rename to server-node/src/core/downloadArchive.ts diff --git a/server/src/core/downloader.ts b/server-node/src/core/downloader.ts similarity index 100% rename from server/src/core/downloader.ts rename to server-node/src/core/downloader.ts diff --git a/server/src/core/states.ts b/server-node/src/core/states.ts similarity index 100% rename from server/src/core/states.ts rename to server-node/src/core/states.ts diff --git a/server/src/core/streamer.ts b/server-node/src/core/streamer.ts similarity index 100% rename from server/src/core/streamer.ts rename to server-node/src/core/streamer.ts diff --git a/server/src/db/memoryDB.ts b/server-node/src/db/memoryDB.ts similarity index 100% rename from server/src/db/memoryDB.ts rename to server-node/src/db/memoryDB.ts diff --git a/server/src/interfaces/IDownloadMetadata.d.ts b/server-node/src/interfaces/IDownloadMetadata.d.ts similarity index 100% rename from server/src/interfaces/IDownloadMetadata.d.ts rename to server-node/src/interfaces/IDownloadMetadata.d.ts diff --git a/server/src/interfaces/IPayload.d.ts b/server-node/src/interfaces/IPayload.d.ts similarity index 100% rename from server/src/interfaces/IPayload.d.ts rename to server-node/src/interfaces/IPayload.d.ts diff --git a/server/src/interfaces/IRecord.d.ts b/server-node/src/interfaces/IRecord.d.ts similarity index 100% rename from server/src/interfaces/IRecord.d.ts rename to server-node/src/interfaces/IRecord.d.ts diff --git a/server/src/interfaces/ISettings.d.ts b/server-node/src/interfaces/ISettings.d.ts similarity index 100% rename from server/src/interfaces/ISettings.d.ts rename to server-node/src/interfaces/ISettings.d.ts diff --git a/server/src/main.ts b/server-node/src/main.ts similarity index 100% rename from server/src/main.ts rename to server-node/src/main.ts diff --git a/server/src/types/index.d.ts b/server-node/src/types/index.d.ts similarity index 100% rename from server/src/types/index.d.ts rename to server-node/src/types/index.d.ts diff --git a/server/src/utils/BetterLogger.ts b/server-node/src/utils/BetterLogger.ts similarity index 100% rename from server/src/utils/BetterLogger.ts rename to server-node/src/utils/BetterLogger.ts diff --git a/server/src/utils/directoryUtils.ts b/server-node/src/utils/directoryUtils.ts similarity index 100% rename from server/src/utils/directoryUtils.ts rename to server-node/src/utils/directoryUtils.ts diff --git a/server/src/utils/logger.ts b/server-node/src/utils/logger.ts similarity index 100% rename from server/src/utils/logger.ts rename to server-node/src/utils/logger.ts diff --git a/server/src/utils/params.ts b/server-node/src/utils/params.ts similarity index 100% rename from server/src/utils/params.ts rename to server-node/src/utils/params.ts diff --git a/server/src/utils/procUtils.ts b/server-node/src/utils/procUtils.ts similarity index 100% rename from server/src/utils/procUtils.ts rename to server-node/src/utils/procUtils.ts diff --git a/server/src/utils/updater.ts b/server-node/src/utils/updater.ts similarity index 100% rename from server/src/utils/updater.ts rename to server-node/src/utils/updater.ts diff --git a/server/cli/ascii.go b/server/cli/ascii.go new file mode 100644 index 0000000..509f1f4 --- /dev/null +++ b/server/cli/ascii.go @@ -0,0 +1,23 @@ +package cli + +import "fmt" + +const ( + // FG + Red = "\033[31m" + Green = "\033[32m" + Yellow = "\033[33m" + Blue = "\033[34m" + Magenta = "\033[35m" + Cyan = "\033[36m" + Reset = "\033[0m" + // BG + BgRed = "\033[1;41m" + BgBlue = "\033[1;44m" + BgGreen = "\033[1;42m" +) + +// Formats a message with the specified ascii escape code, then reset. +func Format(message string, code string) string { + return fmt.Sprintf("%s%s%s", code, message, Reset) +} diff --git a/server/handlers.go b/server/handlers.go new file mode 100644 index 0000000..acf5091 --- /dev/null +++ b/server/handlers.go @@ -0,0 +1,68 @@ +package server + +import ( + "log" + + "github.com/goccy/go-json" + "github.com/gofiber/websocket/v2" +) + +// Websocket handlers + +func download(c *websocket.Conn) { + req := DownloadRequest{} + c.ReadJSON(&req) + + p := Process{mem: &db, url: req.Url, params: req.Params} + p.Start() + + c.WriteJSON(req) +} + +func getFormats(c *websocket.Conn) { + log.Println("Requesting formats") + mtype, msg, _ := c.ReadMessage() + + req := DownloadRequest{} + json.Unmarshal(msg, &req) + + p := Process{mem: &db, url: req.Url} + p.GetFormatsSync() + + c.WriteMessage(mtype, msg) +} + +func status(c *websocket.Conn) { + mtype, _, _ := c.ReadMessage() + + all := db.All() + msg, _ := json.Marshal(all) + + c.WriteMessage(mtype, msg) +} + +func abort(c *websocket.Conn) { + mtype, msg, _ := c.ReadMessage() + + req := AbortRequest{} + json.Unmarshal(msg, &req) + + p := db.Get(req.Id) + p.Kill() + + c.WriteMessage(mtype, msg) +} + +func abortAll(c *websocket.Conn) { + keys := db.Keys() + for _, key := range keys { + proc := db.Get(key) + if proc != nil { + proc.Kill() + } + } +} + +func hotUpdate(c *websocket.Conn) { + +} diff --git a/server/memory_db.go b/server/memory_db.go new file mode 100644 index 0000000..4dd914a --- /dev/null +++ b/server/memory_db.go @@ -0,0 +1,121 @@ +package server + +import ( + "log" + "os" + "sync" + + "github.com/goccy/go-json" + + "github.com/google/uuid" + "github.com/marcopeocchi/yt-dlp-web-ui/server/cli" +) + +// In-Memory volatile Thread-Safe Key-Value Storage +type MemoryDB struct { + table map[string]*Process + mu sync.Mutex +} + +// Inits the db with an empty map of string->Process pointer +func (m *MemoryDB) New() { + m.table = make(map[string]*Process) +} + +// Get a process pointer given its id +func (m *MemoryDB) Get(id string) *Process { + m.mu.Lock() + res := m.table[id] + m.mu.Unlock() + return res +} + +// Store a pointer of a process and return its id +func (m *MemoryDB) Set(process *Process) string { + id := uuid.Must(uuid.NewRandom()).String() + m.mu.Lock() + m.table[id] = process + m.mu.Unlock() + return id +} + +// Update a process info/metadata, given the process id +func (m *MemoryDB) Update(id string, info DownloadInfo) { + m.mu.Lock() + if m.table[id] != nil { + m.table[id].Info = info + } + m.mu.Unlock() +} + +// Update a process progress data, given the process id +// Used for updating completition percentage or ETA +func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) { + m.mu.Lock() + if m.table[id] != nil { + m.table[id].Progress = progress + } + m.mu.Unlock() +} + +// Removes a process progress, given the process id +func (m *MemoryDB) Delete(id string) { + m.mu.Lock() + delete(m.table, id) + m.mu.Unlock() +} + +// Returns a slice of all currently stored processes id +func (m *MemoryDB) Keys() []string { + m.mu.Lock() + keys := make([]string, len(m.table)) + i := 0 + for k := range m.table { + keys[i] = k + i++ + } + m.mu.Unlock() + return keys +} + +// Returns a slice of all currently stored processes progess +func (m *MemoryDB) All() []ProcessResponse { + running := make([]ProcessResponse, len(m.table)) + i := 0 + for k, v := range m.table { + if v != nil { + running[i] = ProcessResponse{ + Id: k, + Info: v.Info, + Progress: v.Progress, + } + i++ + } + } + return running +} + +// WIP: Persist the database in a single file named "session.dat" +func (m *MemoryDB) Persist() { + running := m.All() + + session, err := json.Marshal(Session{ + Processes: running, + }) + if err != nil { + log.Println(cli.Red, "Failed to persist database", cli.Reset) + return + } + + err = os.WriteFile("session.dat", session, 0700) + if err != nil { + log.Println(cli.Red, "Failed to persist database", cli.Reset) + } +} + +// WIP: Restore a persisted state +func (m *MemoryDB) Restore() { + feed, _ := os.ReadFile("session.dat") + session := Session{} + json.Unmarshal(feed, &session) +} diff --git a/server/process.go b/server/process.go new file mode 100644 index 0000000..10ba5d3 --- /dev/null +++ b/server/process.go @@ -0,0 +1,154 @@ +package server + +import ( + "bufio" + + "github.com/goccy/go-json" + + "log" + "os" + "os/exec" + "strings" + "time" + + "github.com/marcopeocchi/yt-dlp-web-ui/server/rx" +) + +const template = `download: +{ + "eta":%(progress.eta)s, + "percentage":"%(progress._percent_str)s", + "speed":%(progress.speed)s +}` + +const driver = "yt-dlp" + +type ProgressTemplate struct { + Percentage string `json:"percentage"` + Speed float32 `json:"speed"` + Size string `json:"size"` + Eta int `json:"eta"` +} + +// Process descriptor +type Process struct { + id string + url string + params []string + Info DownloadInfo + Progress DownloadProgress + mem *MemoryDB + proc *os.Process +} + +// Starts spawns/forks a new yt-dlp process and parse its stdout. +// The process is spawned to outputting a custom progress text that +// Resembles a JSON Object in order to Unmarshal it later. +// This approach is anyhow not perfect: quotes are not escaped properly. +// Each process is not identified by its PID but by a UUIDv2 +func (p *Process) Start() { + params := append([]string{ + strings.Split(p.url, "?list")[0], //no playlist + "--newline", + "--no-colors", + "--no-playlist", + "--progress-template", strings.ReplaceAll(template, "\n", ""), + "-o", + "./downloads/%(title)s.%(ext)s", + }, p.params...) + + // ----------------- main block ----------------- // + cmd := exec.Command(driver, params...) + r, err := cmd.StdoutPipe() + if err != nil { + log.Panicln(err) + } + scan := bufio.NewScanner(r) + + err = cmd.Start() + if err != nil { + log.Panicln(err) + } + + p.id = p.mem.Set(p) + p.proc = cmd.Process + + // ----------------- info block ----------------- // + // spawn a goroutine that retrieves the info for the download + go func() { + cmd := exec.Command(driver, p.url, "-J") + stdout, err := cmd.Output() + if err != nil { + log.Println("Cannot retrieve info for", p.url) + } + info := DownloadInfo{URL: p.url} + json.Unmarshal(stdout, &info) + p.mem.Update(p.id, info) + }() + + // --------------- progress block --------------- // + // unbuffered channe connected to stdout + eventChan := make(chan string) + + // spawn a goroutine that does the dirty job of parsing the stdout + // fill the channel with as many stdout line as yt-dlp produces (producer) + go func() { + defer cmd.Wait() + defer r.Close() + defer p.Complete() + for scan.Scan() { + eventChan <- scan.Text() + } + }() + + // do the unmarshal operation every 500ms (consumer) + go rx.Sample(time.Millisecond*500, eventChan, func(text string) { + stdout := ProgressTemplate{} + err := json.Unmarshal([]byte(text), &stdout) + if err == nil { + p.mem.UpdateProgress(p.id, DownloadProgress{ + Percentage: stdout.Percentage, + Speed: stdout.Speed, + ETA: stdout.Eta, + }) + shortId := strings.Split(p.id, "-")[0] + log.Printf("[%s] %s %s\n", shortId, p.url, p.Progress.Percentage) + } + }) + // ------------- end progress block ------------- // +} + +// Keep process in the memoryDB but marks it as complete +// Convention: All completed processes has progress -1 +// and speed 0 bps. +func (p *Process) Complete() { + p.mem.UpdateProgress(p.id, DownloadProgress{ + Percentage: "-1", + Speed: 0, + ETA: 0, + }) +} + +// Kill a process and remove it from the memory +func (p *Process) Kill() error { + err := p.proc.Kill() + p.mem.Delete(p.id) + log.Printf("Killed process %s\n", p.id) + return err +} + +func (p *Process) GetFormatsSync() (DownloadInfo, error) { + cmd := exec.Command(driver, p.url, "-J") + stdout, err := cmd.Output() + + if err != nil { + return DownloadInfo{}, err + } + + cmd.Wait() + + info := DownloadInfo{URL: p.url} + json.Unmarshal(stdout, &info) + + return info, nil +} diff --git a/server/rpc.go b/server/rpc.go new file mode 100644 index 0000000..ec48f1f --- /dev/null +++ b/server/rpc.go @@ -0,0 +1,39 @@ +package server + +import ( + "bytes" + "io" + "net/rpc/jsonrpc" +) + +// Wrapper for HTTP RPC request that implements io.Reader interface +type rpcRequest struct { + r io.Reader + rw io.ReadWriter + done chan bool +} + +func NewRPCRequest(r io.Reader) *rpcRequest { + var buf bytes.Buffer + done := make(chan bool) + return &rpcRequest{r, &buf, done} +} + +func (r *rpcRequest) Read(p []byte) (n int, err error) { + return r.r.Read(p) +} + +func (r *rpcRequest) Write(p []byte) (n int, err error) { + return r.rw.Write(p) +} + +func (r *rpcRequest) Close() error { + r.done <- true + return nil +} + +func (r *rpcRequest) Call() io.Reader { + go jsonrpc.ServeConn(r) + <-r.done + return r.rw +} diff --git a/server/rx/extensions.go b/server/rx/extensions.go new file mode 100644 index 0000000..9886712 --- /dev/null +++ b/server/rx/extensions.go @@ -0,0 +1,47 @@ +package rx + +import "time" + +/* + Package rx contains: + - Definitions for common reactive programming functions/patterns +*/ + +// ReactiveX inspired debounce function. +// +// Debounce emits a string from the source channel only after a particular +// time span determined a Go Interval +// --A--B--CD--EFG-------|> +// +// -t-> |> +// -t-> |> t is a timer tick +// -t-> |> +// +// --A-----C-----G-------|> +func Debounce(interval time.Duration, source chan string, cb func(emit string)) { + var item string + timer := time.NewTimer(interval) + for { + select { + case item = <-source: + timer.Reset(interval) + case <-timer.C: + if item != "" { + cb(item) + } + } + } +} + +// ReactiveX inspired sample function. +// +// Debounce emits the most recently emitted value from the source +// withing the timespan set by the span time.Duration +func Sample[T any](span time.Duration, source chan T, cb func(emit T)) { + timer := time.NewTimer(span) + for { + <-timer.C + cb(<-source) + timer.Reset(span) + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..ae139e4 --- /dev/null +++ b/server/server.go @@ -0,0 +1,63 @@ +package server + +import ( + "context" + "fmt" + "io/fs" + "log" + "net/http" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" + "github.com/gofiber/websocket/v2" +) + +var db MemoryDB + +func init() { + db.New() +} + +func RunBlocking(ctx context.Context) { + fe := ctx.Value("frontend").(fs.SubFS) + port := ctx.Value("port") + + app := fiber.New() + + app.Use("/", filesystem.New(filesystem.Config{ + Root: http.FS(fe), + })) + + app.Get("/ws", websocket.New(func(c *websocket.Conn) { + for { + mtype, msg, err := c.ReadMessage() + if err != nil { + break + } + + switch string(msg) { + case "send-url-format-selection": + getFormats(c) + case "send-url": + download(c) + case "abort": + abort(c) + case "abort-all": + abortAll(c) + case "status": + status(c) + case "update-bin": + hotUpdate(c) + } + + log.Printf("Read: %s", msg) + + err = c.WriteMessage(mtype, msg) + if err != nil { + break + } + } + })) + + log.Fatal(app.Listen(fmt.Sprintf(":%s", port))) +} diff --git a/server/service.go b/server/service.go new file mode 100644 index 0000000..0e5c8c6 --- /dev/null +++ b/server/service.go @@ -0,0 +1,78 @@ +package server + +import ( + "log" + + "github.com/marcopeocchi/yt-dlp-web-ui/server/sys" +) + +type Service int + +type Running []ProcessResponse +type Pending []string + +type NoArgs struct{} +type Args struct { + Id string + URL string + Params []string +} + +// Exec spawns a Process. +// The result of the execution is the newly spawned process Id. +func (t *Service) Exec(args Args, result *string) error { + log.Printf("Spawning new process for %s\n", args.URL) + p := Process{mem: &db, url: args.URL, params: args.Params} + p.Start() + *result = p.id + return nil +} + +// Progess retrieves the Progress of a specific Process given its Id +func (t *Service) Progess(args Args, progress *DownloadProgress) error { + *progress = db.Get(args.Id).Progress + return nil +} + +// Pending retrieves a slice of all Pending/Running processes ids +func (t *Service) Pending(args NoArgs, pending *Pending) error { + *pending = Pending(db.Keys()) + return nil +} + +// Running retrieves a slice of all Processes progress +func (t *Service) Running(args NoArgs, running *Running) error { + *running = db.All() + return nil +} + +// Kill kills a process given its id and remove it from the memoryDB +func (t *Service) Kill(args string, killed *string) error { + proc := db.Get(args) + var err error + if proc != nil { + err = proc.Kill() + } + return err +} + +// KillAll kills all process unconditionally and removes them from +// the memory db +func (t *Service) KillAll(args NoArgs, killed *string) error { + keys := db.Keys() + var err error + for _, key := range keys { + proc := db.Get(key) + if proc != nil { + proc.Kill() + } + } + return err +} + +// FreeSpace gets the available from package sys util +func (t *Service) FreeSpace(args NoArgs, free *uint64) error { + freeSpace, err := sys.FreeSpace() + *free = freeSpace + return err +} diff --git a/server/sys/fs.go b/server/sys/fs.go new file mode 100644 index 0000000..00ef630 --- /dev/null +++ b/server/sys/fs.go @@ -0,0 +1,20 @@ +package sys + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// package containing fs related operation (unix only) + +// FreeSpace gets the available Bytes writable to download directory +func FreeSpace() (uint64, error) { + var stat unix.Statfs_t + wd, err := os.Getwd() + if err != nil { + return 0, err + } + unix.Statfs(wd+"/downloads", &stat) + return (stat.Bavail * uint64(stat.Bsize)), nil +} diff --git a/server/types.go b/server/types.go new file mode 100644 index 0000000..4138249 --- /dev/null +++ b/server/types.go @@ -0,0 +1,43 @@ +package server + +type DownloadProgress struct { + Percentage string `json:"percentage"` + Speed float32 `json:"speed"` + ETA int `json:"eta"` +} + +type DownloadInfo struct { + URL string `json:"url"` + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + Resolution string `json:"resolution"` + Size int32 `json:"filesize_approx"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Extension string `json:"ext"` +} + +// struct representing the response sent to the client +// as JSON-RPC result field +type ProcessResponse struct { + Id string `json:"id"` + Progress DownloadProgress `json:"progress"` + Info DownloadInfo `json:"info"` +} + +// struct representing the current status of the memoryDB +// used for serializaton/persistence reasons +type Session struct { + Processes []ProcessResponse `json:"processes"` +} + +// struct representing the intent to stop a specific process +type AbortRequest struct { + Id string `json:"id"` +} + +// struct representing the intent to start a download +type DownloadRequest struct { + Url string `json:"url"` + Params []string `json:"params"` +} diff --git a/tsconfig.json b/tsconfig.json index 67b0c3f..f27b390 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,6 @@ "skipLibCheck": true, }, "include": [ - "./server/src/**/*" + "server-node/src/**/*" ] } \ No newline at end of file