Files
hamlab/internal/media/ffrun.go
T
Mitchell-JohnsonandClaude Fable 5.1 1038c010fa Files: a sweep of the edges
Found by walking every handler and the UI with odd inputs, then a battery of requests
against the live hub. Fixed:
- A save over the 8 MiB cap was cut short in place: the file was truncated, then the
  first 8 MiB written, and the caller told 204. Now the body is read whole first and a
  body over the cap is a 413 with the file untouched.
- A link to a file showed the link's own time; now the file's, so the row reads right
  and the preview key follows the file.
- Reading a link to nothing answered 200 with an empty body (the open failed after the
  headers in root mode); now a 404 that names the target.
- A play request with an unknown codec or size was answered by inspecting the file
  first, so an audio-only file said "no video stream" instead of what was wrong; the
  choice is checked before any work, and is a 400.
- A stream that ended in failure was handed back to the next identical request for ten
  minutes; a failed one is now dropped and a fresh one made.
- A player that left while its stream was still finding a first segment made the loop
  try the next recipe; it stops. A stream closed mid-start is not started again.
- Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's
  processes, so the wait is bounded and the helper's own grace does the rest.
- A page going away (a phone closing the tab) releases its stream by a beacon, rather
  than ten minutes later by the sweeper.
- The path box resolves like a shell — relative to the folder shown, . and .. applied,
  trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path
  that is a file opens it; a remembered folder that is gone falls back to / once.
- A dropped folder arrived as a nameless empty file; it is refused with a word. The
  upload toast counts what was actually uploaded.
- The viewer names a device, pipe or socket, and a dangling link, instead of calling
  them binary files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:31:10 -07:00

189 lines
4.4 KiB
Go

package media
// Running one long ffmpeg: as the daemon's user directly, or as root through this binary's
// run-ffmpeg helper when the file needs root to read. The helper is told to finish by its
// standard input closing, which also happens by itself if the daemon goes away — so a
// transcode never outlives the console that asked for it.
import (
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"hamlab/internal/priv"
)
type ffproc struct {
cmd *exec.Cmd
in io.WriteCloser // the helper's stdin, root mode only
done chan struct{}
err error
tail *tailBuf
}
// startFF launches ffmpeg with args, under nice where there is one.
func startFF(c Caps, self string, root bool, args []string) (*ffproc, error) {
var argv []string
if root {
argv = priv.Wrap(append([]string{self, "run-ffmpeg"}, args...)...)
} else {
argv = append([]string{c.FFmpeg}, args...)
if c.Nice != "" {
argv = append([]string{c.Nice, "-n", "10"}, argv...)
}
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
p := &ffproc{cmd: cmd, done: make(chan struct{}), tail: &tailBuf{}}
cmd.Stderr = p.tail
cmd.Stdout = io.Discard
if root {
in, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
p.in = in
}
if err := cmd.Start(); err != nil {
return nil, err
}
go func() {
p.err = cmd.Wait()
close(p.done)
}()
return p, nil
}
// exited reports whether ffmpeg has finished, and how.
func (p *ffproc) exited() (bool, error) {
select {
case <-p.done:
return true, p.err
default:
return false, nil
}
}
// end asks ffmpeg to finish and waits a little for it.
func (p *ffproc) end() {
if done, _ := p.exited(); done {
return
}
if p.in != nil {
p.in.Close()
} else {
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
}
select {
case <-p.done:
case <-time.After(5 * time.Second):
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) // a root helper ignores this; it ends on its own within its own grace
select {
case <-p.done:
case <-time.After(10 * time.Second):
}
}
}
// lastWords is what ffmpeg said last, for the record.
func (p *ffproc) lastWords() string { return p.tail.last() }
// tailBuf keeps the last few KB written to it.
type tailBuf struct {
mu sync.Mutex
b []byte
}
const tailKeep = 4096
func (t *tailBuf) Write(b []byte) (int, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.b = append(t.b, b...)
if len(t.b) > tailKeep {
t.b = t.b[len(t.b)-tailKeep:]
}
return len(b), nil
}
func (t *tailBuf) last() string {
t.mu.Lock()
defer t.mu.Unlock()
return lastLine(string(bytes.TrimSpace(t.b)))
}
// RunFFmpegHelper implements `hamlab run-ffmpeg <args…>`: ffmpeg as this user (root, when
// the daemon asked through sudo), finishing when stdin closes. Returns ffmpeg's exit code.
func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
io.WriteString(stderr, "ffmpeg is not installed\n")
return 1
}
argv := []string{ffmpeg}
if nice, err := exec.LookPath("nice"); err == nil {
argv = []string{nice, "-n", "10", ffmpeg}
}
cmd := exec.Command(argv[0], append(argv[1:], args...)...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Stderr = stderr
cmd.Stdout = io.Discard
if err := cmd.Start(); err != nil {
io.WriteString(stderr, err.Error()+"\n")
return 1
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
_, _ = io.Copy(io.Discard, stdin) // returns when the daemon lets go
cancel()
}()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var werr error
select {
case werr = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
select {
case werr = <-done:
case <-time.After(5 * time.Second):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
werr = <-done
}
}
var ee *exec.ExitError
if errors.As(werr, &ee) {
return ee.ExitCode()
}
if werr != nil {
return 1
}
return 0
}
// clearDir empties a folder without removing it.
func clearDir(dir string) error {
ents, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range ents {
if err := os.RemoveAll(dir + "/" + e.Name()); err != nil {
return err
}
}
return nil
}
func hasSegments(playlist string) bool {
b, err := os.ReadFile(playlist)
return err == nil && strings.Contains(string(b), "#EXTINF")
}