266 lines
5.4 KiB
Go
266 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Hub fans server-sent events out to every connected editor window.
|
|
type Hub struct {
|
|
mu sync.Mutex
|
|
subs map[chan []byte]struct{}
|
|
}
|
|
|
|
func newHub() *Hub { return &Hub{subs: map[chan []byte]struct{}{}} }
|
|
|
|
func (h *Hub) Subscribe() chan []byte {
|
|
ch := make(chan []byte, 256)
|
|
h.mu.Lock()
|
|
h.subs[ch] = struct{}{}
|
|
h.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
func (h *Hub) Unsubscribe(ch chan []byte) {
|
|
h.mu.Lock()
|
|
delete(h.subs, ch)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
func (h *Hub) Publish(event string, v any) {
|
|
msg := sseMessage(event, v)
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
for ch := range h.subs {
|
|
select {
|
|
case ch <- msg:
|
|
default: // slow client; it will catch up on the next update
|
|
}
|
|
}
|
|
}
|
|
|
|
func sseMessage(event string, v any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, b))
|
|
}
|
|
|
|
// Job is a long-running ffmpeg task (an export or a preview proxy).
|
|
type Job struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title"`
|
|
SourceID string `json:"sourceId,omitempty"`
|
|
State string `json:"state"` // running, done, error, canceled
|
|
Progress float64 `json:"progress"`
|
|
Speed string `json:"speed,omitempty"`
|
|
ETA float64 `json:"eta"`
|
|
Elapsed float64 `json:"elapsed"`
|
|
Step string `json:"step,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Outputs []string `json:"outputs,omitempty"`
|
|
|
|
cancel context.CancelFunc
|
|
started time.Time
|
|
lastSent time.Time
|
|
}
|
|
|
|
type JobManager struct {
|
|
mu sync.Mutex
|
|
jobs map[string]*Job
|
|
hub *Hub
|
|
wg sync.WaitGroup
|
|
onDone func()
|
|
}
|
|
|
|
func newJobManager(hub *Hub, onDone func()) *JobManager {
|
|
return &JobManager{jobs: map[string]*Job{}, hub: hub, onDone: onDone}
|
|
}
|
|
|
|
// JobCtl is handed to a running job so it can report progress.
|
|
type JobCtl struct {
|
|
m *JobManager
|
|
job *Job
|
|
}
|
|
|
|
func (c *JobCtl) Step(s string) {
|
|
c.m.mu.Lock()
|
|
c.job.Step = s
|
|
c.m.mu.Unlock()
|
|
c.m.publish(c.job, true)
|
|
}
|
|
|
|
func (c *JobCtl) Progress(p float64, speed string) {
|
|
if p < 0 {
|
|
p = 0
|
|
}
|
|
if p > 1 {
|
|
p = 1
|
|
}
|
|
c.m.mu.Lock()
|
|
j := c.job
|
|
j.Progress = p
|
|
j.Speed = speed
|
|
j.Elapsed = time.Since(j.started).Seconds()
|
|
if p > 0.01 {
|
|
j.ETA = j.Elapsed / p * (1 - p)
|
|
}
|
|
c.m.mu.Unlock()
|
|
c.m.publish(j, false)
|
|
}
|
|
|
|
// AddOutput records a finished output file.
|
|
func (c *JobCtl) AddOutput(path string) {
|
|
c.m.mu.Lock()
|
|
c.job.Outputs = append(c.job.Outputs, path)
|
|
c.m.mu.Unlock()
|
|
}
|
|
|
|
func (m *JobManager) Start(kind, title, sourceID string, fn func(ctx context.Context, c *JobCtl) error) *Job {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
j := &Job{
|
|
ID: randomHex(6),
|
|
Kind: kind,
|
|
Title: title,
|
|
SourceID: sourceID,
|
|
State: "running",
|
|
cancel: cancel,
|
|
started: time.Now(),
|
|
}
|
|
m.mu.Lock()
|
|
m.jobs[j.ID] = j
|
|
m.pruneLocked()
|
|
m.mu.Unlock()
|
|
m.publish(j, true)
|
|
|
|
m.wg.Add(1)
|
|
go func() {
|
|
defer m.wg.Done()
|
|
defer cancel()
|
|
err := fn(ctx, &JobCtl{m: m, job: j})
|
|
m.mu.Lock()
|
|
j.Elapsed = time.Since(j.started).Seconds()
|
|
j.ETA = 0
|
|
switch {
|
|
case ctx.Err() != nil:
|
|
j.State = "canceled"
|
|
case err != nil:
|
|
j.State = "error"
|
|
j.Error = err.Error()
|
|
default:
|
|
j.State = "done"
|
|
j.Progress = 1
|
|
}
|
|
state, elapsed := j.State, j.Elapsed
|
|
m.mu.Unlock()
|
|
if kind == "export" {
|
|
if err != nil && state == "error" {
|
|
log.Printf("export failed after %.1fs: %v", elapsed, err)
|
|
} else {
|
|
log.Printf("export %s after %.1fs", state, elapsed)
|
|
}
|
|
}
|
|
m.publish(j, true)
|
|
if m.onDone != nil {
|
|
m.onDone()
|
|
}
|
|
}()
|
|
return j
|
|
}
|
|
|
|
func (m *JobManager) publish(j *Job, force bool) {
|
|
m.mu.Lock()
|
|
if !force && time.Since(j.lastSent) < 200*time.Millisecond {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
j.lastSent = time.Now()
|
|
snap := *j
|
|
m.mu.Unlock()
|
|
m.hub.Publish("job", &snap)
|
|
}
|
|
|
|
// pruneLocked forgets old finished jobs.
|
|
func (m *JobManager) pruneLocked() {
|
|
var finished []*Job
|
|
for _, j := range m.jobs {
|
|
if j.State != "running" {
|
|
finished = append(finished, j)
|
|
}
|
|
}
|
|
if len(finished) <= 20 {
|
|
return
|
|
}
|
|
sort.Slice(finished, func(a, b int) bool { return finished[a].started.Before(finished[b].started) })
|
|
for _, j := range finished[:len(finished)-20] {
|
|
delete(m.jobs, j.ID)
|
|
}
|
|
}
|
|
|
|
func (m *JobManager) Snapshot() []Job {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]Job, 0, len(m.jobs))
|
|
for _, j := range m.jobs {
|
|
out = append(out, *j)
|
|
}
|
|
sort.Slice(out, func(a, b int) bool { return out[a].started.Before(out[b].started) })
|
|
return out
|
|
}
|
|
|
|
func (m *JobManager) Cancel(id string) bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if j, ok := m.jobs[id]; ok && j.State == "running" {
|
|
j.cancel()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *JobManager) CancelAll() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, j := range m.jobs {
|
|
if j.State == "running" {
|
|
j.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find returns the running job of the given kind for a source, if any.
|
|
func (m *JobManager) Find(kind, sourceID string) *Job {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, j := range m.jobs {
|
|
if j.Kind == kind && j.SourceID == sourceID && j.State == "running" {
|
|
return j
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *JobManager) AnyRunning(kind string) bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, j := range m.jobs {
|
|
if j.Kind == kind && j.State == "running" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *JobManager) Wait(timeout time.Duration) {
|
|
done := make(chan struct{})
|
|
go func() { m.wg.Wait(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(timeout):
|
|
}
|
|
}
|