265
jobs.go
Normal file
265
jobs.go
Normal file
@@ -0,0 +1,265 @@
|
||||
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):
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user