Feat livestream support (#180)

* experimental livestrea support

* test livestream

* update wait time detection

* update livestream functions

* persist and restore livestreams monitor session

* fan-in logging

* deps update

* added live time display

* livestream monitor prototype

* changed to default logger instead of passing *slog.Logger everywhere

* code refactoring, comments
This commit is contained in:
Marco Piovanello
2024-08-19 22:08:09 +02:00
committed by GitHub
parent a64798644a
commit fd5e62e23b
25 changed files with 865 additions and 95 deletions

View File

@@ -0,0 +1,193 @@
package livestream
import (
"bufio"
"errors"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
const (
waiting = iota
inProgress
completed
errored
)
// Defines a generic livestream.
// A livestream is identified by its url.
type LiveStream struct {
url string
proc *os.Process // used to manually kill the yt-dlp process
status int // whether is monitoring or completed
log chan []byte // keeps tracks of the process logs while monitoring, not when started
done chan *LiveStream // where to signal the completition
waitTimeChan chan time.Duration // time to livestream start
errors chan error
waitTime time.Duration
liveDate time.Time
}
func New(url string, log chan []byte, done chan *LiveStream) *LiveStream {
return &LiveStream{
url: url,
done: done,
status: waiting,
waitTime: time.Second * 0,
log: log,
errors: make(chan error),
waitTimeChan: make(chan time.Duration),
}
}
// Start the livestream monitoring process, once completion signals on the done channel
func (l *LiveStream) Start() error {
cmd := exec.Command(
config.Instance().DownloaderPath,
l.url,
"--wait-for-video", "10", // wait for the stream to be live and recheck every 10 secs
"--no-colors", // no ansi color fuzz
"--paths", config.Instance().DownloadPath,
)
l.proc = cmd.Process
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
defer stdout.Close()
if err := cmd.Start(); err != nil {
l.status = errored
return err
}
l.status = waiting
// Start monitoring when the livestream is goin to be live.
// If already live do nothing.
go l.monitorStartTime(stdout)
// Wait to the yt-dlp+ffmpeg process to finish.
cmd.Wait()
// Set the job as completed and notify the parent the completion.
l.status = completed
l.done <- l
return nil
}
func (l *LiveStream) monitorStartTime(r io.Reader) error {
// yt-dlp shows the time in the stdout
scanner := bufio.NewScanner(r)
defer func() {
close(l.waitTimeChan)
close(l.errors)
}()
// however the time to live is not shown in a new line (and atm there's nothing to do about)
// use a custom split funciton to set the line separator to \r instead of \r\n or \n
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data); i++ {
if data[i] == '\r' {
return i + 1, data[:i], nil
}
}
if !atEOF {
return 0, nil, nil
}
return 0, data, bufio.ErrFinalToken
})
// start scanning the stdout
for scanner.Scan() {
// l.log <- scanner.Bytes()
parts := strings.Split(scanner.Text(), ": ")
if len(parts) < 2 {
continue
}
// if this substring is in the current line the download is starting,
// no need to monitor the time to live.
//TODO: silly
if !strings.Contains(scanner.Text(), "Remaining time until next attempt") {
l.status = inProgress
return nil
}
startsIn := parts[1]
parsed, err := parseTimeSpan(startsIn)
if err != nil {
continue
}
l.liveDate = parsed
//TODO: check if useing channels is stupid or not
// l.waitTimeChan <- time.Until(start)
l.waitTime = time.Until(parsed)
}
return nil
}
func (l *LiveStream) WaitTime() <-chan time.Duration {
return l.waitTimeChan
}
// Kills a livestream process and signal its completition
func (l *LiveStream) Kill() error {
l.done <- l
if l.proc != nil {
return l.proc.Kill()
}
return errors.New("nil yt-dlp process")
}
// Parse the timespan returned from yt-dlp (time to live)
//
// parsed := parseTimeSpan("76:12:15")
// fmt.Println(parsed) // 2024-07-21 13:59:59.634781 +0200 CEST
func parseTimeSpan(timeStr string) (time.Time, error) {
parts := strings.Split(timeStr, ":")
hh, err := strconv.Atoi(parts[0])
if err != nil {
return time.Time{}, err
}
mm, err := strconv.Atoi(parts[1])
if err != nil {
return time.Time{}, err
}
ss, err := strconv.Atoi(parts[2])
if err != nil {
return time.Time{}, err
}
dd := 0
if hh > 24 {
dd = hh / 24
hh = hh % 24
}
start := time.Now()
start = start.AddDate(0, 0, dd)
start = start.Add(time.Duration(hh) * time.Hour)
start = start.Add(time.Duration(mm) * time.Minute)
start = start.Add(time.Duration(ss) * time.Second)
return start, nil
}

View File

@@ -0,0 +1,36 @@
package livestream
import (
"testing"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
func setupTest() {
config.Instance().DownloaderPath = "yt-dlp"
}
func TestLivestream(t *testing.T) {
setupTest()
done := make(chan *LiveStream)
log := make(chan []byte)
ls := New("https://www.youtube.com/watch?v=LSm1daKezcE", log, done)
go ls.Start()
time.AfterFunc(time.Second*20, func() {
ls.Kill()
})
for {
select {
case wt := <-ls.WaitTime():
t.Log(wt)
case <-done:
t.Log("done")
return
}
}
}

View File

@@ -0,0 +1,118 @@
package livestream
import (
"encoding/gob"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
type Monitor struct {
streams map[string]*LiveStream // keeps track of the livestreams
done chan *LiveStream // to signal individual processes completition
logs chan []byte // to signal individual processes completition
}
func NewMonitor() *Monitor {
return &Monitor{
streams: make(map[string]*LiveStream),
done: make(chan *LiveStream),
}
}
// Detect each livestream completition, if done remove it from the monitor.
func (m *Monitor) Schedule() {
for l := range m.done {
delete(m.streams, l.url)
}
}
func (m *Monitor) Add(url string) {
ls := New(url, m.logs, m.done)
go ls.Start()
m.streams[url] = ls
}
func (m *Monitor) Remove(url string) error {
return m.streams[url].Kill()
}
func (m *Monitor) RemoveAll() error {
for _, v := range m.streams {
if err := v.Kill(); err != nil {
return err
}
}
return nil
}
func (m *Monitor) Status() LiveStreamStatus {
status := make(LiveStreamStatus)
for k, v := range m.streams {
// wt, ok := <-v.WaitTime()
// if !ok {
// continue
// }
status[k] = struct {
Status int
WaitTime time.Duration
LiveDate time.Time
}{
Status: v.status,
WaitTime: v.waitTime,
LiveDate: v.liveDate,
}
}
return status
}
// Persist the monitor current state to a file.
// The file is located in the configured config directory
func (m *Monitor) Persist() error {
fd, err := os.Create(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
slog.Debug("persisting livestream monitor state")
return gob.NewEncoder(fd).Encode(m.streams)
}
// Restore a saved state and resume the monitored livestreams
func (m *Monitor) Restore() error {
fd, err := os.Open(filepath.Join(config.Instance().Dir(), "livestreams.dat"))
if err != nil {
return err
}
defer fd.Close()
restored := make(map[string]*LiveStream)
if err := gob.NewDecoder(fd).Decode(&restored); err != nil {
return err
}
for k := range restored {
m.Add(k)
}
slog.Debug("restored livestream monitor state")
return nil
}
// Return a fan-in logs channel
func (m *Monitor) Logs() <-chan []byte {
return m.logs
}

View File

@@ -0,0 +1 @@
package livestream

View File

@@ -0,0 +1,11 @@
package livestream
import "time"
type LiveStreamStatus = map[string]Status
type Status = struct {
Status int
WaitTime time.Duration
LiveDate time.Time
}