mirror of
				https://github.com/mickael-kerjean/filestash.git
				synced 2025-11-04 13:35:46 +08:00 
			
		
		
		
	up until now, the stance was to refuse video thumbnail because it's too slow but really many people don't seem to care that much about it and keep insisting to have it. With this solution, it's not in the base build but it gives an option for those people to make it happen
		
			
				
	
	
		
			262 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			262 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package plg_video_transcoder
 | 
						|
 | 
						|
import (
 | 
						|
	"bytes"
 | 
						|
	"encoding/base64"
 | 
						|
	"encoding/json"
 | 
						|
	"fmt"
 | 
						|
	"io"
 | 
						|
	"math"
 | 
						|
	"net/http"
 | 
						|
	"os"
 | 
						|
	"os/exec"
 | 
						|
	"strconv"
 | 
						|
	"strings"
 | 
						|
	"time"
 | 
						|
 | 
						|
	"github.com/gorilla/mux"
 | 
						|
	. "github.com/mickael-kerjean/filestash/server/common"
 | 
						|
	. "github.com/mickael-kerjean/filestash/server/middleware"
 | 
						|
)
 | 
						|
 | 
						|
const (
 | 
						|
	HLS_SEGMENT_LENGTH = 5
 | 
						|
	FPS                = 30
 | 
						|
	CLEAR_CACHE_AFTER  = 12
 | 
						|
	VideoCachePath     = "data/cache/video/"
 | 
						|
)
 | 
						|
 | 
						|
var (
 | 
						|
	plugin_enable    func() bool
 | 
						|
	blacklist_format func() string
 | 
						|
)
 | 
						|
 | 
						|
func init() {
 | 
						|
	if _, err := exec.LookPath("ffmpeg"); err != nil {
 | 
						|
		Hooks.Register.Onload(func() {
 | 
						|
			Log.Warning("plg_video_thumbnail::init error=ffmpeg_not_installed")
 | 
						|
		})
 | 
						|
		return
 | 
						|
	}
 | 
						|
	if _, err := exec.LookPath("ffprobe"); err != nil {
 | 
						|
		Hooks.Register.Onload(func() {
 | 
						|
			Log.Warning("plg_video_thumbnail::init error=ffprobe_not_installed")
 | 
						|
		})
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	plugin_enable = func() bool {
 | 
						|
		return Config.Get("features.video.enable_transcoder").Schema(func(f *FormElement) *FormElement {
 | 
						|
			if f == nil {
 | 
						|
				f = &FormElement{}
 | 
						|
			}
 | 
						|
			f.Name = "enable_transcoder"
 | 
						|
			f.Type = "enable"
 | 
						|
			f.Target = []string{"transcoding_blacklist_format"}
 | 
						|
			f.Description = "Enable/Disable on demand video transcoding. The transcoder"
 | 
						|
			f.Default = true
 | 
						|
			return f
 | 
						|
		}).Bool()
 | 
						|
	}
 | 
						|
	blacklist_format = func() string {
 | 
						|
		return Config.Get("features.video.blacklist_format").Schema(func(f *FormElement) *FormElement {
 | 
						|
			if f == nil {
 | 
						|
				f = &FormElement{}
 | 
						|
			}
 | 
						|
			f.Id = "transcoding_blacklist_format"
 | 
						|
			f.Name = "blacklist_format"
 | 
						|
			f.Type = "text"
 | 
						|
			f.Description = "Video format that won't be transcoded"
 | 
						|
			f.Default = os.Getenv("FEATURE_TRANSCODING_VIDEO_BLACKLIST")
 | 
						|
			if f.Default != "" {
 | 
						|
				f.Placeholder = fmt.Sprintf("Default: '%s'", f.Default)
 | 
						|
			}
 | 
						|
			return f
 | 
						|
		}).String()
 | 
						|
	}
 | 
						|
 | 
						|
	Hooks.Register.Onload(func() {
 | 
						|
		blacklist_format()
 | 
						|
		plugin_enable()
 | 
						|
 | 
						|
		cachePath := GetAbsolutePath(VideoCachePath)
 | 
						|
		os.RemoveAll(cachePath)
 | 
						|
		os.MkdirAll(cachePath, os.ModePerm)
 | 
						|
	})
 | 
						|
 | 
						|
	Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
 | 
						|
		r.HandleFunc(OverrideVideoSourceMapper, func(res http.ResponseWriter, req *http.Request) {
 | 
						|
			res.Header().Set("Content-Type", GetMimeType(req.URL.String()))
 | 
						|
			if plugin_enable() == false {
 | 
						|
				return
 | 
						|
			}
 | 
						|
			res.Write([]byte(`window.overrides["video-map-sources"] = function(sources){`))
 | 
						|
			res.Write([]byte(`    return sources.map(function(source){`))
 | 
						|
 | 
						|
			blacklists := strings.Split(blacklist_format(), ",")
 | 
						|
			for i := 0; i < len(blacklists); i++ {
 | 
						|
				blacklists[i] = strings.TrimSpace(blacklists[i])
 | 
						|
				res.Write([]byte(fmt.Sprintf(`if(source.type == "%s"){ return source; } `, GetMimeType("."+blacklists[i]))))
 | 
						|
			}
 | 
						|
			res.Write([]byte(`        source.src = source.src + "&transcode=hls";`))
 | 
						|
			res.Write([]byte(`        source.type = "application/x-mpegURL";`))
 | 
						|
			res.Write([]byte(`        return source;`))
 | 
						|
			res.Write([]byte(`    })`))
 | 
						|
			res.Write([]byte(`}`))
 | 
						|
		})
 | 
						|
		return nil
 | 
						|
	})
 | 
						|
	Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
 | 
						|
		r.PathPrefix("/hls/hls_{segment}.ts").Handler(NewMiddlewareChain(
 | 
						|
			hlsTranscodeHandler,
 | 
						|
			[]Middleware{SecureHeaders},
 | 
						|
			*app,
 | 
						|
		)).Methods("GET")
 | 
						|
		return nil
 | 
						|
	})
 | 
						|
	Hooks.Register.ProcessFileContentBeforeSend(hlsPlaylistHandler)
 | 
						|
}
 | 
						|
 | 
						|
func hlsPlaylistHandler(reader io.ReadCloser, ctx *App, res *http.ResponseWriter, req *http.Request) (io.ReadCloser, error) {
 | 
						|
	query := req.URL.Query()
 | 
						|
	if query.Get("transcode") != "hls" {
 | 
						|
		return reader, nil
 | 
						|
	}
 | 
						|
	path := query.Get("path")
 | 
						|
	if strings.HasPrefix(GetMimeType(path), "video/") == false {
 | 
						|
		return reader, nil
 | 
						|
	}
 | 
						|
 | 
						|
	cacheName := "vid_" + GenerateID(ctx.Session) + "_" + QuickHash(path, 10) + ".dat"
 | 
						|
	cachePath := GetAbsolutePath(
 | 
						|
		VideoCachePath,
 | 
						|
		cacheName,
 | 
						|
	)
 | 
						|
	f, err := os.OpenFile(cachePath, os.O_CREATE|os.O_RDWR, os.ModePerm)
 | 
						|
	if err != nil {
 | 
						|
		Log.Stdout("ERR %+v", err)
 | 
						|
		return reader, err
 | 
						|
	}
 | 
						|
	io.Copy(f, reader)
 | 
						|
	reader.Close()
 | 
						|
	f.Close()
 | 
						|
	time.AfterFunc(CLEAR_CACHE_AFTER*time.Hour, func() { os.Remove(cachePath) })
 | 
						|
 | 
						|
	p, err := ffprobe(cachePath)
 | 
						|
	if err != nil {
 | 
						|
		return reader, err
 | 
						|
	}
 | 
						|
 | 
						|
	var response string
 | 
						|
	var i int
 | 
						|
	response = "#EXTM3U\n"
 | 
						|
	response += "#EXT-X-VERSION:3\n"
 | 
						|
	response += "#EXT-X-MEDIA-SEQUENCE:0\n"
 | 
						|
	response += "#EXT-X-ALLOW-CACHE:YES\n"
 | 
						|
	response += fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", HLS_SEGMENT_LENGTH)
 | 
						|
	for i = 0; i < int(p.Format.Duration)/HLS_SEGMENT_LENGTH; i++ {
 | 
						|
		response += fmt.Sprintf("#EXTINF:%d.0000, nodesc\n", HLS_SEGMENT_LENGTH)
 | 
						|
		response += fmt.Sprintf("/hls/hls_%d.ts?path=%s\n", i, cacheName)
 | 
						|
	}
 | 
						|
	if md := math.Mod(p.Format.Duration, HLS_SEGMENT_LENGTH); md > 0 {
 | 
						|
		response += fmt.Sprintf("#EXTINF:%.4f, nodesc\n", md)
 | 
						|
		response += fmt.Sprintf("/hls/hls_%d.ts?path=%s\n", i, cacheName)
 | 
						|
	}
 | 
						|
	response += "#EXT-X-ENDLIST\n"
 | 
						|
	(*res).Header().Set("Content-Type", "application/x-mpegURL")
 | 
						|
	return NewReadCloserFromBytes([]byte(response)), nil
 | 
						|
}
 | 
						|
 | 
						|
func hlsTranscodeHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
 | 
						|
	if plugin_enable() == false {
 | 
						|
		return
 | 
						|
	}
 | 
						|
	segmentNumber, err := strconv.Atoi(mux.Vars(req)["segment"])
 | 
						|
	if err != nil {
 | 
						|
		Log.Info("[plugin hls] invalid segment request '%s'", mux.Vars(req)["segment"])
 | 
						|
		res.WriteHeader(http.StatusBadRequest)
 | 
						|
		return
 | 
						|
	}
 | 
						|
	startTime := segmentNumber * HLS_SEGMENT_LENGTH
 | 
						|
	cachePath := GetAbsolutePath(
 | 
						|
		VideoCachePath,
 | 
						|
		req.URL.Query().Get("path"),
 | 
						|
	)
 | 
						|
	if _, err := os.Stat(cachePath); os.IsNotExist(err) {
 | 
						|
		Log.Info("[plugin hls]: invalid video")
 | 
						|
		res.WriteHeader(http.StatusServiceUnavailable)
 | 
						|
		return
 | 
						|
	}
 | 
						|
 | 
						|
	cmd := exec.CommandContext(req.Context(), "ffmpeg", []string{
 | 
						|
		"-timelimit", "30",
 | 
						|
		"-ss", fmt.Sprintf("%d.00", startTime),
 | 
						|
		"-i", cachePath,
 | 
						|
		"-t", fmt.Sprintf("%d.00", HLS_SEGMENT_LENGTH),
 | 
						|
		"-vf", fmt.Sprintf("scale=-2:%d", 720),
 | 
						|
		"-vcodec", "libx264",
 | 
						|
		"-preset", "veryfast",
 | 
						|
		"-acodec", "aac",
 | 
						|
		"-ab", "128k",
 | 
						|
		"-ac", "2",
 | 
						|
		"-pix_fmt", "yuv420p",
 | 
						|
		"-x264opts", strings.Join([]string{
 | 
						|
			"subme=0",
 | 
						|
			"me_range=4",
 | 
						|
			"rc_lookahead=10",
 | 
						|
			"me=dia",
 | 
						|
			"no_chroma_me",
 | 
						|
			"8x8dct=0",
 | 
						|
			"partitions=none",
 | 
						|
		}, ":"),
 | 
						|
		"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d.000)", HLS_SEGMENT_LENGTH),
 | 
						|
		"-f", "ssegment",
 | 
						|
		"-segment_time", fmt.Sprintf("%d.00", HLS_SEGMENT_LENGTH),
 | 
						|
		"-segment_start_number", fmt.Sprintf("%d", segmentNumber),
 | 
						|
		"-initial_offset", fmt.Sprintf("%d.00", startTime),
 | 
						|
		"-vsync", "2",
 | 
						|
		"pipe:out%03d.ts",
 | 
						|
	}...)
 | 
						|
 | 
						|
	var buffer bytes.Buffer
 | 
						|
	cmd.Stdout = res
 | 
						|
	cmd.Stderr = &buffer
 | 
						|
	err = cmd.Run()
 | 
						|
	if err != nil {
 | 
						|
		Log.Error("plg_video_transcoder::ffmpeg::run '%s' - %s", err.Error(), base64.StdEncoding.EncodeToString(buffer.Bytes()))
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
type FFProbeData struct {
 | 
						|
	Format struct {
 | 
						|
		Duration float64 `json:"duration,string"`
 | 
						|
		BitRate  int     `json:"bit_rate,string"`
 | 
						|
	} `json: "format"`
 | 
						|
	Streams []struct {
 | 
						|
		CodecType   string `json:"codec_type"`
 | 
						|
		CodecName   string `json:"codec_name"`
 | 
						|
		PixelFormat string `json:"pix_fmt"`
 | 
						|
	} `json:"streams"`
 | 
						|
}
 | 
						|
 | 
						|
func ffprobe(videoPath string) (FFProbeData, error) {
 | 
						|
	var stream bytes.Buffer
 | 
						|
	var probe FFProbeData
 | 
						|
 | 
						|
	cmd := exec.Command(
 | 
						|
		"ffprobe", strings.Split(fmt.Sprintf(
 | 
						|
			"-v quiet -print_format json -show_format -show_streams %s",
 | 
						|
			videoPath,
 | 
						|
		), " ")...,
 | 
						|
	)
 | 
						|
	cmd.Stdout = &stream
 | 
						|
	if err := cmd.Run(); err != nil {
 | 
						|
		return probe, nil
 | 
						|
	}
 | 
						|
	cmd.Run()
 | 
						|
	if err := json.Unmarshal([]byte(stream.String()), &probe); err != nil {
 | 
						|
		return probe, err
 | 
						|
	}
 | 
						|
	return probe, nil
 | 
						|
}
 |