301 lines
6.7 KiB
Go
301 lines
6.7 KiB
Go
package preview
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"convert/internal/media"
|
|
)
|
|
|
|
type Server struct {
|
|
addr string
|
|
httpSrv *http.Server
|
|
ffmpegBin string
|
|
mu sync.RWMutex
|
|
files map[string]registeredFile
|
|
proxyLocks map[string]*sync.Mutex
|
|
proxyCache map[string]string
|
|
thumbCache map[string][]byte
|
|
tempFiles []string
|
|
}
|
|
|
|
type registeredFile struct {
|
|
info *media.VideoInfo
|
|
}
|
|
|
|
func Start(ffmpegBin string) (*Server, error) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &Server{
|
|
addr: ln.Addr().String(),
|
|
ffmpegBin: ffmpegBin,
|
|
files: make(map[string]registeredFile),
|
|
proxyLocks: make(map[string]*sync.Mutex),
|
|
proxyCache: make(map[string]string),
|
|
thumbCache: make(map[string][]byte),
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/video", s.handleVideo)
|
|
mux.HandleFunc("/proxy", s.handleProxy)
|
|
mux.HandleFunc("/thumb", s.handleThumb)
|
|
|
|
s.httpSrv = &http.Server{
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
_ = s.httpSrv.Serve(ln)
|
|
}()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) SetFFmpeg(bin string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.ffmpegBin = bin
|
|
}
|
|
|
|
func (s *Server) BaseURL() string {
|
|
return "http://" + s.addr
|
|
}
|
|
|
|
func (s *Server) Register(info *media.VideoInfo) string {
|
|
sum := sha1.Sum([]byte(info.Path))
|
|
id := hex.EncodeToString(sum[:8])
|
|
|
|
s.mu.Lock()
|
|
s.files[id] = registeredFile{info: info}
|
|
s.mu.Unlock()
|
|
return id
|
|
}
|
|
|
|
func (s *Server) Close() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if s.httpSrv != nil {
|
|
_ = s.httpSrv.Shutdown(ctx)
|
|
}
|
|
s.mu.Lock()
|
|
for _, p := range s.tempFiles {
|
|
_ = os.Remove(p)
|
|
}
|
|
s.tempFiles = nil
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Server) getFile(id string) (registeredFile, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
rf, ok := s.files[id]
|
|
return rf, ok
|
|
}
|
|
|
|
func (s *Server) handleVideo(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("id")
|
|
rf, ok := s.getFile(id)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f, err := os.Open(rf.info.Path)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
http.ServeContent(w, r, rf.info.FileName, st.ModTime(), f)
|
|
}
|
|
|
|
func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("id")
|
|
merge := r.URL.Query().Get("merge") == "1" || r.URL.Query().Get("merge") == "true"
|
|
rf, ok := s.getFile(id)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
ffmpegBin := s.ffmpegBin
|
|
s.mu.RUnlock()
|
|
if ffmpegBin == "" {
|
|
http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s_m%v", id, merge)
|
|
s.mu.Lock()
|
|
lk, exists := s.proxyLocks[cacheKey]
|
|
if !exists {
|
|
lk = &sync.Mutex{}
|
|
s.proxyLocks[cacheKey] = lk
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
lk.Lock()
|
|
defer lk.Unlock()
|
|
|
|
s.mu.RLock()
|
|
proxyPath := s.proxyCache[cacheKey]
|
|
s.mu.RUnlock()
|
|
|
|
if proxyPath == "" {
|
|
proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey))
|
|
if err := s.buildProxy(r.Context(), ffmpegBin, rf.info, merge, proxyPath); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.proxyCache[cacheKey] = proxyPath
|
|
s.tempFiles = append(s.tempFiles, proxyPath)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
f, err := os.Open(proxyPath)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
http.ServeContent(w, r, "preview.mp4", st.ModTime(), f)
|
|
}
|
|
|
|
func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.VideoInfo, merge bool, outPath string) error {
|
|
args := []string{"-hide_banner", "-loglevel", "error", "-y", "-i", info.Path}
|
|
|
|
numAudio := len(info.AudioStreams)
|
|
if numAudio == 0 {
|
|
args = append(args, "-map", "0:v:0", "-an")
|
|
} else if merge && numAudio > 1 {
|
|
var in strings.Builder
|
|
for i := 0; i < numAudio; i++ {
|
|
in.WriteString(fmt.Sprintf("[0:a:%d]", i))
|
|
}
|
|
args = append(args,
|
|
"-filter_complex", fmt.Sprintf("%samix=inputs=%d:normalize=0[a]", in.String(), numAudio),
|
|
"-map", "0:v:0", "-map", "[a]",
|
|
"-c:a", "aac", "-b:a", "128k",
|
|
)
|
|
} else {
|
|
args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k")
|
|
}
|
|
|
|
if info.VideoCodec == "h264" {
|
|
// Remux video stream instantaneously when codec is already H.264
|
|
args = append(args, "-c:v", "copy")
|
|
} else {
|
|
args = append(args,
|
|
"-c:v", "libx264",
|
|
"-preset", "ultrafast",
|
|
"-crf", "26",
|
|
"-pix_fmt", "yuv420p",
|
|
"-vf", "scale='min(1280,iw)':-2",
|
|
)
|
|
}
|
|
args = append(args, "-movflags", "+faststart", outPath)
|
|
|
|
cmd := exec.CommandContext(ctx, ffmpegBin, args...)
|
|
media.HideCmdWindow(cmd)
|
|
var errBuf bytes.Buffer
|
|
cmd.Stderr = &errBuf
|
|
if err := cmd.Run(); err != nil {
|
|
_ = os.Remove(outPath)
|
|
return fmt.Errorf("preview proxy error: %v (%s)", err, strings.TrimSpace(errBuf.String()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("id")
|
|
tStr := r.URL.Query().Get("t")
|
|
rf, ok := s.getFile(id)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
tSec, err := strconv.ParseFloat(tStr, 64)
|
|
if err != nil || tSec < 0 {
|
|
tSec = 0
|
|
}
|
|
if rf.info.Duration > 0 && tSec >= rf.info.Duration {
|
|
tSec = rf.info.Duration - 0.1
|
|
if tSec < 0 {
|
|
tSec = 0
|
|
}
|
|
}
|
|
|
|
key := fmt.Sprintf("%s_%.2f", id, tSec)
|
|
s.mu.RLock()
|
|
cached, found := s.thumbCache[key]
|
|
ffmpegBin := s.ffmpegBin
|
|
s.mu.RUnlock()
|
|
|
|
if !found {
|
|
if ffmpegBin == "" {
|
|
http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
cmd := exec.CommandContext(r.Context(), ffmpegBin,
|
|
"-hide_banner", "-loglevel", "error",
|
|
"-ss", strconv.FormatFloat(tSec, 'f', 3, 64),
|
|
"-i", rf.info.Path,
|
|
"-frames:v", "1",
|
|
"-vf", "scale=200:-1",
|
|
"-q:v", "6",
|
|
"-f", "image2pipe",
|
|
"-vcodec", "mjpeg",
|
|
"pipe:1",
|
|
)
|
|
media.HideCmdWindow(cmd)
|
|
data, err := cmd.Output()
|
|
if err != nil || len(data) == 0 {
|
|
http.Error(w, "thumbnail generation failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
if len(s.thumbCache) > 200 {
|
|
// simple cap reset
|
|
s.thumbCache = make(map[string][]byte)
|
|
}
|
|
s.thumbCache[key] = data
|
|
s.mu.Unlock()
|
|
cached = data
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
_, _ = w.Write(cached)
|
|
}
|