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>
189 lines
4.4 KiB
Go
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")
|
|
}
|