Files
vibedit/jobs.go
Sean MTS c6ac7d2222 Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:28:47 +00:00

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):
}
}