Every video plays YouTube's way: hls.js reads the connection and picks the quality, or one is picked by hand, or Original plays the file as it is. The qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead. 360 videos open in a look-around view (drag, pinch or scroll to zoom from 8 to 300 degrees, phone motion). Settings -> Transcode device picks the machine and device that makes them, for every machine's files: a machine transcoding another's file reads it through the hub by a token for that one file. Replaces the codec/size picker and the single-quality re-encoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
463 lines
13 KiB
Go
463 lines
13 KiB
Go
package media
|
|
|
|
// Playing a video in the browser at the quality the connection can take: HLS, made on the fly
|
|
// by ffmpeg on the device asked for (the Transcode device setting), YouTube's way. The player
|
|
// is told every quality is all there from the start, in Seg-second pieces, and each piece is
|
|
// made when it is first asked for.
|
|
//
|
|
// One ffmpeg runs per play at a time. It starts at the piece asked for and runs on ahead of the
|
|
// player, paused once it is far enough ahead; a piece asked for well past where it has got to
|
|
// (a seek), or at another quality, ends it and starts a new one there. Every run is made the
|
|
// same way (pieceArgs), so pieces from different runs and qualities follow on from one another.
|
|
//
|
|
// The file is a path on this machine or, when the device asked for is on another one, a URL
|
|
// the hub serves it at. Nothing is kept: a play nobody asks anything of is forgotten, and the
|
|
// folder is emptied when the daemon starts.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
runAhead = 10 // pieces a run may make past the last one asked for before it is paused
|
|
jump = 3 // a piece more than this far past what's made is a seek: start again there
|
|
idleRun = 45 * time.Second // a run nobody has asked anything of for this long is ended
|
|
idlePlay = 30 * time.Minute // a play nobody has asked anything of for this long is forgotten
|
|
budget = 2 << 30 // bytes of pieces one play keeps; those asked for least lately go first
|
|
// PieceWait is the longest a request waits for its piece: under the hub proxy's 60 s wait
|
|
// for a node to answer.
|
|
PieceWait = 45 * time.Second
|
|
)
|
|
|
|
var ErrNotFound = errors.New("no such piece")
|
|
|
|
// Plays is every play on this machine.
|
|
type Plays struct {
|
|
dir string
|
|
self string // this binary, for root runs through run-ffmpeg
|
|
maxRuns int
|
|
|
|
mu sync.Mutex
|
|
plays map[string]*Play
|
|
stop chan struct{}
|
|
}
|
|
|
|
// NewPlays keeps pieces under <dataDir>/transcode, which it empties first.
|
|
func NewPlays(dataDir, self string, maxRuns int) *Plays {
|
|
dir := filepath.Join(dataDir, "transcode")
|
|
_ = os.MkdirAll(dir, 0o700)
|
|
_ = clearDir(dir)
|
|
m := &Plays{dir: dir, self: self, maxRuns: max(maxRuns, 1), plays: map[string]*Play{}, stop: make(chan struct{})}
|
|
go m.tend()
|
|
return m
|
|
}
|
|
|
|
// Play is one video opened for watching.
|
|
type Play struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // the file's name, for the record
|
|
Info Info `json:"info"`
|
|
Rungs []Rung `json:"rungs"` // best first
|
|
Device string `json:"device"` // asked for: a render node, "cpu", or "" for the best here
|
|
Remote bool `json:"remote"` // the file is on another machine, read over the network
|
|
|
|
caps Caps
|
|
in string
|
|
root bool
|
|
segs int
|
|
dir string
|
|
m *Plays
|
|
|
|
mu sync.Mutex
|
|
seen time.Time
|
|
run *playRun
|
|
tried map[int]int // per rung: recipes that failed to make anything, skipped from now on
|
|
}
|
|
|
|
type playRun struct {
|
|
rung int
|
|
recipe Recipe
|
|
start int // the first piece this run makes
|
|
head int // the last piece finished (start-1 before the first)
|
|
want int // the last piece asked for
|
|
asked time.Time
|
|
proc *ffproc
|
|
paused bool
|
|
dead bool // ended, or exited
|
|
killed bool // ended by us, not by itself
|
|
exited bool
|
|
err error
|
|
tick chan struct{} // closed when head moves or the run ends
|
|
}
|
|
|
|
func newPlayID() string {
|
|
b := make([]byte, 12)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// Open starts a play of in (a path, or an http URL for a file on another machine). Nothing
|
|
// runs until a piece is asked for.
|
|
func (m *Plays) Open(c Caps, root bool, in, name string, src Info, device string) (*Play, error) {
|
|
rungs := Ladder(src)
|
|
if len(rungs) == 0 || src.Duration <= 0 {
|
|
return nil, errors.New("this video's length or picture size isn't known, so it can't be played")
|
|
}
|
|
if len(recipes(c, device, src, rungs[0])) == 0 {
|
|
return nil, errors.New("nothing on this machine encodes H.264 (no GPU that does, and no libx264)")
|
|
}
|
|
segs := max(int(math.Ceil((src.Duration-0.25)/Seg)), 1) // the last piece takes in what's left
|
|
p := &Play{ID: newPlayID(), Name: name, Info: src, Rungs: rungs, Device: device, Remote: strings.HasPrefix(in, "http"),
|
|
caps: c, in: in, root: root, segs: segs, m: m, seen: time.Now(), tried: map[int]int{}}
|
|
p.dir = filepath.Join(m.dir, p.ID)
|
|
m.mu.Lock()
|
|
m.plays[p.ID] = p
|
|
m.mu.Unlock()
|
|
return p, nil
|
|
}
|
|
|
|
func (m *Plays) Find(id string) (*Play, bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p, ok := m.plays[id]
|
|
return p, ok
|
|
}
|
|
|
|
// Close ends a play and deletes its pieces.
|
|
func (m *Plays) Close(id string) {
|
|
m.mu.Lock()
|
|
p, ok := m.plays[id]
|
|
delete(m.plays, id)
|
|
m.mu.Unlock()
|
|
if ok {
|
|
p.mu.Lock()
|
|
p.end()
|
|
p.mu.Unlock()
|
|
go os.RemoveAll(p.dir)
|
|
}
|
|
}
|
|
|
|
func (p *Play) rung(name string) int {
|
|
for i, r := range p.Rungs {
|
|
if r.Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Master is the playlist of qualities, lowest first, so a player that starts on the first one
|
|
// listed starts on the one that surely plays.
|
|
func (p *Play) Master() []byte {
|
|
var b bytes.Buffer
|
|
b.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-INDEPENDENT-SEGMENTS\n")
|
|
for i := len(p.Rungs) - 1; i >= 0; i-- {
|
|
r := p.Rungs[i]
|
|
fmt.Fprintf(&b, "#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,RESOLUTION=%dx%d,FRAME-RATE=%.3f,CODECS=\"%s\"\n%s.m3u8\n",
|
|
r.Peak, r.Bitrate+audioRate, r.Width, r.Height, r.FPS, r.codecs, r.Name)
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
// Media is one quality's playlist: every piece, made or not.
|
|
func (p *Play) Media(name string) ([]byte, bool) {
|
|
if p.rung(name) < 0 {
|
|
return nil, false
|
|
}
|
|
var b bytes.Buffer
|
|
last := p.Info.Duration - float64(p.segs-1)*Seg
|
|
fmt.Fprintf(&b, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n",
|
|
int(math.Ceil(math.Max(last, Seg))))
|
|
for n := 0; n < p.segs; n++ {
|
|
d := Seg
|
|
if n == p.segs-1 {
|
|
d = last
|
|
}
|
|
fmt.Fprintf(&b, "#EXTINF:%.6f,\n%s/%d.ts\n", d, name, n)
|
|
}
|
|
b.WriteString("#EXT-X-ENDLIST\n")
|
|
return b.Bytes(), true
|
|
}
|
|
|
|
func (p *Play) piece(rung, n int) string {
|
|
return filepath.Join(p.dir, p.Rungs[rung].Name, strconv.Itoa(n)+".ts")
|
|
}
|
|
|
|
// Segment answers the file of piece n at quality name, making it first if need be: it waits
|
|
// for the run already making it, or starts one there.
|
|
func (p *Play) Segment(done <-chan struct{}, name string, n int) (string, error) {
|
|
ri := p.rung(name)
|
|
if ri < 0 || n < 0 || n >= p.segs {
|
|
return "", ErrNotFound
|
|
}
|
|
path := p.piece(ri, n)
|
|
deadline := time.After(PieceWait)
|
|
for {
|
|
p.mu.Lock()
|
|
now := time.Now()
|
|
p.seen = now
|
|
r := p.run
|
|
if r != nil && r.rung == ri && !r.dead {
|
|
r.want, r.asked = n, now
|
|
}
|
|
if exists(path) {
|
|
p.mu.Unlock()
|
|
_ = os.Chtimes(path, now, now) // the budget keeps what was asked for last
|
|
return path, nil
|
|
}
|
|
if r != nil && r.exited && !r.killed && r.rung == ri && r.start == n {
|
|
// This run started at this very piece and ended without it: that way of making
|
|
// it doesn't work here. The next one is tried, if there is one.
|
|
p.tried[ri]++
|
|
log.Printf("files: play %s: %s made nothing at %s piece %d: %v: %s", p.Name, r.recipe.Engine, name, n, r.err, r.proc.lastWords())
|
|
if p.tried[ri] >= len(recipes(p.caps, p.Device, p.Info, p.Rungs[ri])) {
|
|
err := fmt.Errorf("couldn't make %s: %s", p.Rungs[ri].Label, r.proc.lastWords())
|
|
p.mu.Unlock()
|
|
return "", err
|
|
}
|
|
p.run = nil
|
|
r = nil
|
|
}
|
|
if r == nil || r.dead || r.rung != ri || n < r.start || n <= r.head || n > r.head+jump {
|
|
p.mu.Unlock()
|
|
p.m.makeRoom(p)
|
|
p.mu.Lock()
|
|
if p.run == r { // nobody started one meanwhile
|
|
if err := p.start(ri, n); err != nil {
|
|
p.mu.Unlock()
|
|
return "", err
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
continue
|
|
}
|
|
tick := r.tick
|
|
p.mu.Unlock()
|
|
select {
|
|
case <-tick:
|
|
case <-time.After(200 * time.Millisecond):
|
|
case <-done:
|
|
return "", errors.New("gone")
|
|
case <-deadline:
|
|
return "", fmt.Errorf("%s piece %d took longer than %s", name, n, PieceWait)
|
|
}
|
|
}
|
|
}
|
|
|
|
// end stops this play's run. p.mu is held.
|
|
func (p *Play) end() {
|
|
if r := p.run; r != nil && !r.dead {
|
|
r.dead, r.killed = true, true
|
|
r.proc.kill()
|
|
}
|
|
}
|
|
|
|
// makeRoom ends the run asked of least lately, if every slot is taken by another play. It
|
|
// never holds two plays' locks at once.
|
|
func (m *Plays) makeRoom(except *Play) {
|
|
m.mu.Lock()
|
|
plays := make([]*Play, 0, len(m.plays))
|
|
for _, p := range m.plays {
|
|
if p != except {
|
|
plays = append(plays, p)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
var oldest *Play
|
|
var when time.Time
|
|
running := 0
|
|
for _, p := range plays {
|
|
p.mu.Lock()
|
|
if r := p.run; r != nil && !r.dead {
|
|
running++
|
|
if oldest == nil || r.asked.Before(when) {
|
|
oldest, when = p, r.asked
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
}
|
|
if running >= m.maxRuns && oldest != nil {
|
|
oldest.mu.Lock()
|
|
oldest.end()
|
|
oldest.mu.Unlock()
|
|
log.Printf("files: %d plays encoding; ended %s's, asked of least lately", running, oldest.Name)
|
|
}
|
|
}
|
|
|
|
// start begins a run making quality ri from piece n on, ending the one before. p.mu is held.
|
|
func (p *Play) start(ri, n int) error {
|
|
p.end()
|
|
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[ri])
|
|
if p.tried[ri] >= len(rs) {
|
|
return errors.New("nothing here can make this quality")
|
|
}
|
|
rc := rs[p.tried[ri]]
|
|
out := filepath.Join(p.dir, p.Rungs[ri].Name)
|
|
if err := os.MkdirAll(out, 0o700); err != nil {
|
|
return err
|
|
}
|
|
proc, err := startFF(p.caps, p.m.self, p.root, pieceArgs(out, p.in, p.Info, rc, p.Rungs[ri], n))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r := &playRun{rung: ri, recipe: rc, start: n, head: n - 1, want: n, asked: time.Now(), proc: proc, tick: make(chan struct{})}
|
|
p.run = r
|
|
go func() {
|
|
<-proc.done
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
r.dead, r.exited, r.err = true, true, proc.err
|
|
p.scan(r)
|
|
close(r.tick)
|
|
r.tick = make(chan struct{})
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// scan moves a run's head on past the pieces it has finished. p.mu is held.
|
|
func (p *Play) scan(r *playRun) bool {
|
|
moved := false
|
|
for r.head+1 < p.segs && exists(p.piece(r.rung, r.head+1)) {
|
|
r.head++
|
|
moved = true
|
|
}
|
|
return moved
|
|
}
|
|
|
|
// PlayStatus is what a play is doing, for the player's menu.
|
|
type PlayStatus struct {
|
|
Engine string `json:"engine,omitempty"` // what the run going now runs on
|
|
Quality string `json:"quality,omitempty"`
|
|
From int `json:"from"` // seconds into the film the run started at
|
|
Ahead int `json:"ahead"`
|
|
Paused bool `json:"paused,omitempty"`
|
|
}
|
|
|
|
func (p *Play) Status() PlayStatus {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
r := p.run
|
|
if r == nil || r.dead {
|
|
return PlayStatus{}
|
|
}
|
|
return PlayStatus{Engine: r.recipe.Engine, Quality: p.Rungs[r.rung].Label, From: int(float64(r.start) * Seg), Ahead: r.head - r.want, Paused: r.paused}
|
|
}
|
|
|
|
// Engine is what this play would run on first, for the player to say before anything runs.
|
|
func (p *Play) Engine() string {
|
|
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[0])
|
|
if len(rs) == 0 {
|
|
return ""
|
|
}
|
|
return rs[0].Engine
|
|
}
|
|
|
|
// tend, ten times a second: notices finished pieces, pauses a run that is far enough ahead and
|
|
// wakes it when the player catches up, ends one nobody is asking anything of; and every ten
|
|
// seconds keeps each play inside its budget and forgets plays nobody watches.
|
|
func (m *Plays) tend() {
|
|
t := time.NewTicker(100 * time.Millisecond)
|
|
defer t.Stop()
|
|
for i := 0; ; i++ {
|
|
select {
|
|
case <-m.stop:
|
|
return
|
|
case <-t.C:
|
|
}
|
|
m.mu.Lock()
|
|
plays := make([]*Play, 0, len(m.plays))
|
|
for _, p := range m.plays {
|
|
plays = append(plays, p)
|
|
}
|
|
m.mu.Unlock()
|
|
now := time.Now()
|
|
for _, p := range plays {
|
|
p.mu.Lock()
|
|
if r := p.run; r != nil && !r.dead {
|
|
if p.scan(r) {
|
|
close(r.tick)
|
|
r.tick = make(chan struct{})
|
|
}
|
|
ahead := r.head - r.want
|
|
switch {
|
|
case now.Sub(r.asked) > idleRun:
|
|
p.end()
|
|
case !r.paused && ahead >= runAhead:
|
|
if r.proc.pause() == nil {
|
|
r.paused = true
|
|
}
|
|
case r.paused && ahead < runAhead-3:
|
|
if r.proc.resume() == nil {
|
|
r.paused = false
|
|
}
|
|
}
|
|
}
|
|
idle := now.Sub(p.seen) > idlePlay
|
|
p.mu.Unlock()
|
|
if idle {
|
|
m.Close(p.ID)
|
|
} else if i%100 == 0 {
|
|
p.trim()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// trim deletes the pieces asked for least lately until the play is inside its budget, never
|
|
// the ones its run has made ahead of the player.
|
|
func (p *Play) trim() {
|
|
type piece struct {
|
|
path string
|
|
size int64
|
|
at time.Time
|
|
}
|
|
var all []piece
|
|
var total int64
|
|
_ = filepath.WalkDir(p.dir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".ts") {
|
|
return nil
|
|
}
|
|
if fi, err := d.Info(); err == nil {
|
|
all = append(all, piece{path, fi.Size(), fi.ModTime()})
|
|
total += fi.Size()
|
|
}
|
|
return nil
|
|
})
|
|
if total <= budget {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
keep := map[string]bool{}
|
|
if r := p.run; r != nil && !r.dead {
|
|
for n := r.want - 1; n <= r.head+1; n++ {
|
|
if n >= 0 && n < p.segs {
|
|
keep[p.piece(r.rung, n)] = true
|
|
}
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
sort.Slice(all, func(a, b int) bool { return all[a].at.Before(all[b].at) })
|
|
for _, x := range all {
|
|
if total <= budget*3/4 {
|
|
break
|
|
}
|
|
if !keep[x.path] && os.Remove(x.path) == nil {
|
|
total -= x.size
|
|
}
|
|
}
|
|
}
|