Browse Source

Added experimental realtime transcode

Toby Chui 1 year ago
parent
commit
b335e1fa6a

+ 2 - 0
main.flags.go

@@ -10,6 +10,7 @@ import (
 	db "imuslab.com/arozos/mod/database"
 	"imuslab.com/arozos/mod/disk/raid"
 	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/media/mediaserver"
 	permission "imuslab.com/arozos/mod/permission"
 	user "imuslab.com/arozos/mod/user"
 	"imuslab.com/arozos/mod/www"
@@ -27,6 +28,7 @@ var userHandler *user.UserHandler         //User Handler
 var packageManager *apt.AptPackageManager //Manager for package auto installation
 var raidManager *raid.Manager             //Software RAID Manager, only activate on Linux hosts
 var userWwwHandler *www.Handler           //User Webroot handler
+var mediaServer *mediaserver.Instance     //Media handling server for streaming and downloading large files
 var subserviceBasePort = 12810            //Next subservice port
 
 // =========== SYSTEM BUILD INFORMATION ==============

+ 41 - 289
mediaServer.go

@@ -1,22 +1,11 @@
 package main
 
 import (
-	"crypto/md5"
-	"encoding/hex"
-	"errors"
-	"io"
 	"net/http"
 	"net/url"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"time"
 
-	"imuslab.com/arozos/mod/compatibility"
-	"imuslab.com/arozos/mod/filesystem"
-	fs "imuslab.com/arozos/mod/filesystem"
-	"imuslab.com/arozos/mod/utils"
+	"imuslab.com/arozos/mod/apt"
+	"imuslab.com/arozos/mod/media/mediaserver"
 )
 
 /*
@@ -33,290 +22,53 @@ PLEASE ALWAYS USE URLENCODE IN THE LINK PASSED INTO THE /media ENDPOINT
 */
 
 func mediaServer_init() {
-	http.HandleFunc("/media/", serverMedia)
-	http.HandleFunc("/media/getMime/", serveMediaMime)
-	http.HandleFunc("/media/download/", serverMedia)
-}
-
-// This function validate the incoming media request and return fsh, vpath, rpath and err if any
-func media_server_validateSourceFile(w http.ResponseWriter, r *http.Request) (*filesystem.FileSystemHandler, string, string, error) {
-	username, err := authAgent.GetUserName(w, r)
-	if err != nil {
-		return nil, "", "", errors.New("User not logged in")
-	}
-
-	userinfo, _ := userHandler.GetUserInfoFromUsername(username)
-
-	//Validate url valid
-	if strings.Count(r.URL.String(), "?") > 1 {
-		return nil, "", "", errors.New("Invalid paramters. Multiple ? found")
-	}
-
-	targetfile, _ := utils.GetPara(r, "file")
-	targetfile, err = url.QueryUnescape(targetfile)
-	if err != nil {
-		return nil, "", "", err
-	}
-	if targetfile == "" {
-		return nil, "", "", errors.New("Missing paramter 'file'")
-	}
-
-	//Translate the virtual directory to realpath
-	fsh, subpath, err := GetFSHandlerSubpathFromVpath(targetfile)
-	if err != nil {
-		return nil, "", "", errors.New("Unable to load from target file system")
-	}
-	fshAbs := fsh.FileSystemAbstraction
-	realFilepath, err := fshAbs.VirtualPathToRealPath(subpath, userinfo.Username)
-	if fshAbs.FileExists(realFilepath) && fshAbs.IsDir(realFilepath) {
-		return nil, "", "", errors.New("Given path is not a file")
-	}
-	if err != nil {
-		return nil, "", "", errors.New("Unable to translate the given filepath")
-	}
-
-	if !fshAbs.FileExists(realFilepath) {
-		//Sometime if url is not URL encoded, this error might be shown as well
-
-		//Try to use manual segmentation
-
-		originalURL := r.URL.String()
-		//Must be pre-processed with system special URI Decode function to handle edge cases
-		originalURL = fs.DecodeURI(originalURL)
-		if strings.Contains(originalURL, "&download=true") {
-			originalURL = strings.ReplaceAll(originalURL, "&download=true", "")
-		} else if strings.Contains(originalURL, "download=true") {
-			originalURL = strings.ReplaceAll(originalURL, "download=true", "")
-		}
-		if strings.Contains(originalURL, "&file=") {
-			originalURL = strings.ReplaceAll(originalURL, "&file=", "file=")
-		}
-		urlInfo := strings.Split(originalURL, "file=")
-		possibleVirtualFilePath := urlInfo[len(urlInfo)-1]
-		possibleRealpath, err := fshAbs.VirtualPathToRealPath(possibleVirtualFilePath, userinfo.Username)
-		if err != nil {
-			systemWideLogger.PrintAndLog("Media Server", "Error when trying to serve file in compatibility mode", err)
-			return nil, "", "", errors.New("Error when trying to serve file in compatibility mode")
-		}
-		if fshAbs.FileExists(possibleRealpath) {
-			realFilepath = possibleRealpath
-			systemWideLogger.PrintAndLog("Media Server", "Serving file "+filepath.Base(possibleRealpath)+" in compatibility mode. Do not to use '&' or '+' sign in filename! ", nil)
-			return fsh, targetfile, realFilepath, nil
-		} else {
-			return nil, "", "", errors.New("File not exists")
-		}
-	}
-
-	return fsh, targetfile, realFilepath, nil
-}
-
-func serveMediaMime(w http.ResponseWriter, r *http.Request) {
-	targetFsh, _, realFilepath, err := media_server_validateSourceFile(w, r)
-	if err != nil {
-		utils.SendErrorResponse(w, err.Error())
-		return
-	}
-	targetFshAbs := targetFsh.FileSystemAbstraction
-	if targetFsh.RequireBuffer {
-		//File is not on local. Guess its mime by extension
-		utils.SendTextResponse(w, "application/"+filepath.Ext(realFilepath)[1:])
-		return
-	}
-
-	mime := "text/directory"
-	if !targetFshAbs.IsDir(realFilepath) {
-		m, _, err := fs.GetMime(realFilepath)
-		if err != nil {
-			mime = ""
-		}
-		mime = m
-	}
-
-	utils.SendTextResponse(w, mime)
-}
-
-func serverMedia(w http.ResponseWriter, r *http.Request) {
-	userinfo, _ := userHandler.GetUserInfoFromRequest(w, r)
-	//Serve normal media files
-	targetFsh, vpath, realFilepath, err := media_server_validateSourceFile(w, r)
-	if err != nil {
-		utils.SendErrorResponse(w, err.Error())
-		return
-	}
-
-	targetFshAbs := targetFsh.FileSystemAbstraction
-
-	//Check if downloadMode
-	downloadMode := false
-	dw, _ := utils.GetPara(r, "download")
-	if dw == "true" {
-		downloadMode = true
-	}
-
-	//New download implementations, allow /download to be used instead of &download=true
-	if strings.Contains(r.RequestURI, "media/download/?file=") {
-		downloadMode = true
-	}
-
-	//Serve the file
-	if downloadMode {
-		escapedRealFilepath, err := url.PathUnescape(realFilepath)
-		if err != nil {
-			utils.SendErrorResponse(w, err.Error())
-			return
-		}
-		filename := filepath.Base(escapedRealFilepath)
-
-		w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
-		w.Header().Set("Content-Type", compatibility.BrowserCompatibilityOverrideContentType(r.UserAgent(), filename, r.Header.Get("Content-Type")))
-		if targetFsh.RequireBuffer || !filesystem.FileExists(realFilepath) {
-			//Stream it directly from remote
-			w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
-			remoteStream, err := targetFshAbs.ReadStream(realFilepath)
-			if err != nil {
-				utils.SendErrorResponse(w, err.Error())
-				return
-			}
-			io.Copy(w, remoteStream)
-			remoteStream.Close()
-		} else {
-			http.ServeFile(w, r, escapedRealFilepath)
-		}
-
+	//Create a media server
+	mediaServer = mediaserver.NewMediaServer(&mediaserver.Options{
+		BufferPoolSize:      *bufferPoolSize,
+		BufferFileMaxSize:   *bufferFileMaxSize,
+		EnableFileBuffering: *enable_buffering,
+		TmpDirectory:        *tmp_directory,
+		Authagent:           authAgent,
+		UserHandler:         userHandler,
+		Logger:              systemWideLogger,
+	})
+
+	//Setup the virtual path resolver
+	mediaServer.SetVirtualPathResolver(GetFSHandlerSubpathFromVpath)
+
+	//Register media serving endpoints
+	http.HandleFunc("/media/", mediaServer.ServerMedia)
+	http.HandleFunc("/media/download/", mediaServer.ServerMedia) //alias for &download=xxx
+	http.HandleFunc("/media/getMime/", mediaServer.ServeMediaMime)
+
+	//Check if ffmpeg exists
+	ffmpegInstalled, _ := apt.PackageExists("ffmpeg")
+	if ffmpegInstalled {
+		//ffmpeg installed. allow transcode
+		http.HandleFunc("/media/transcode/", mediaServer.ServeVideoWithTranscode)
 	} else {
-		if targetFsh.RequireBuffer {
-			w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
-			//Check buffer exists
-			ps, _ := targetFsh.GetUniquePathHash(vpath, userinfo.Username)
-			buffpool := filepath.Join(*tmp_directory, "fsbuffpool")
-			buffFile := filepath.Join(buffpool, ps)
-			if fs.FileExists(buffFile) {
-				//Stream the buff file if hash matches
-				remoteFileHash, err := getHashFromRemoteFile(targetFsh.FileSystemAbstraction, realFilepath)
-				if err == nil {
-					localFileHash, err := os.ReadFile(buffFile + ".hash")
-					if err == nil {
-						if string(localFileHash) == remoteFileHash {
-							//Hash matches. Serve local buffered file
-							http.ServeFile(w, r, buffFile)
-							return
-						}
-					}
-				}
-
-			}
-
-			remoteStream, err := targetFshAbs.ReadStream(realFilepath)
-			if err != nil {
-				utils.SendErrorResponse(w, err.Error())
-				return
-			}
-			defer remoteStream.Close()
-			io.Copy(w, remoteStream)
+		//ffmpeg not installed. Redirect transcode endpoint back to /media/
+		http.HandleFunc("/media/transcode/", func(w http.ResponseWriter, r *http.Request) {
+			// Extract the original query parameters
+			originalURL := r.URL
+			queryParams := originalURL.RawQuery
 
-			if *enable_buffering {
-				os.MkdirAll(buffpool, 0775)
-				go func() {
-					BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath)
-				}()
-			}
+			// Define the new base URL for redirection
+			newBaseURL := "/media/"
 
-		} else if !filesystem.FileExists(realFilepath) {
-			//Streaming from remote file system that support fseek
-			f, err := targetFsh.FileSystemAbstraction.Open(realFilepath)
+			// Parse the new base URL
+			newURL, err := url.Parse(newBaseURL)
 			if err != nil {
-				w.WriteHeader(http.StatusInternalServerError)
-				w.Write([]byte("500 - Internal Server Error"))
+				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 				return
 			}
-			fstat, _ := f.Stat()
-			defer f.Close()
-			http.ServeContent(w, r, filepath.Base(realFilepath), fstat.ModTime(), f)
-		} else {
-			http.ServeFile(w, r, realFilepath)
-		}
 
-	}
-
-}
-
-func BufferRemoteFileToTmp(buffFile string, fsh *filesystem.FileSystemHandler, rpath string) error {
-	if fs.FileExists(buffFile + ".download") {
-		return errors.New("another buffer process running")
-	}
-
-	//Generate a stat file for the buffer
-	hash, err := getHashFromRemoteFile(fsh.FileSystemAbstraction, rpath)
-	if err != nil {
-		//Do not buffer
-		return err
-	}
-	os.WriteFile(buffFile+".hash", []byte(hash), 0775)
-
-	//Buffer the file from remote to local
-	f, err := fsh.FileSystemAbstraction.ReadStream(rpath)
-	if err != nil {
-		os.Remove(buffFile + ".hash")
-		return err
-	}
-	defer f.Close()
-
-	dest, err := os.OpenFile(buffFile+".download", os.O_CREATE|os.O_WRONLY, 0775)
-	if err != nil {
-		os.Remove(buffFile + ".hash")
-		return err
-	}
-	defer dest.Close()
-
-	io.Copy(dest, f)
-	f.Close()
-	dest.Close()
-
-	os.Rename(buffFile+".download", buffFile)
-
-	//Clean the oldest buffpool item if size too large
-	dirsize, _ := fs.GetDirctorySize(filepath.Dir(buffFile), false)
-	oldestModtime := time.Now().Unix()
-	oldestFile := ""
-	for int(dirsize) > *bufferPoolSize<<20 {
-		//fmt.Println("CLEARNING BUFF", dirsize)
-		files, _ := filepath.Glob(filepath.ToSlash(filepath.Dir(buffFile)) + "/*")
-		for _, file := range files {
-			if filepath.Ext(file) == ".hash" {
-				continue
-			}
-			thisModTime, _ := fs.GetModTime(file)
-			if thisModTime < oldestModtime {
-				oldestModtime = thisModTime
-				oldestFile = file
-			}
-		}
-
-		os.Remove(oldestFile)
-		os.Remove(oldestFile + ".hash")
-
-		dirsize, _ = fs.GetDirctorySize(filepath.Dir(buffFile), false)
-		oldestModtime = time.Now().Unix()
-	}
-	return nil
-}
-
-func getHashFromRemoteFile(fshAbs filesystem.FileSystemAbstraction, rpath string) (string, error) {
-	filestat, err := fshAbs.Stat(rpath)
-	if err != nil {
-		//Always pull from remote
-		return "", err
-	}
-
-	if filestat.Size() >= int64(*bufferPoolSize<<20) {
-		return "", errors.New("Unable to buffer: file larger than buffpool size")
-	}
+			// Append the original query parameters to the new URL
+			newURL.RawQuery = queryParams
 
-	if filestat.Size() >= int64(*bufferFileMaxSize<<20) {
-		return "", errors.New("File larger than max buffer file size")
+			// Perform the redirection
+			http.Redirect(w, r, newURL.String(), http.StatusFound)
+		})
 	}
 
-	statHash := strconv.Itoa(int(filestat.ModTime().Unix() + filestat.Size()))
-	hash := md5.Sum([]byte(statHash))
-	return hex.EncodeToString(hash[:]), nil
 }

+ 455 - 0
mod/media/mediaserver/mediaserver.go

@@ -0,0 +1,455 @@
+package mediaserver
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"errors"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"imuslab.com/arozos/mod/auth"
+	"imuslab.com/arozos/mod/compatibility"
+	"imuslab.com/arozos/mod/filesystem"
+	fs "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/media/transcoder"
+	"imuslab.com/arozos/mod/user"
+	"imuslab.com/arozos/mod/utils"
+)
+
+/*
+	Media Server
+
+	This script handle serving of media file types and abstractize the
+	legacy media.go file
+
+	author: tobychui 2024
+*/
+
+type Options struct {
+	BufferPoolSize      int    //Buffer pool size for all media files buffered in this host
+	BufferFileMaxSize   int    //Max size per file in buffer pool
+	EnableFileBuffering bool   //Allow remote file system to buffer files to this host tmp folder for faster access
+	TmpDirectory        string //Directory to store the buffer pool. will create a folder named "fsbuffpool" inside the given path
+
+	Authagent   *auth.AuthAgent
+	UserHandler *user.UserHandler
+	Logger      *logger.Logger
+}
+
+type Instance struct {
+	options             *Options
+	VirtualPathResolver func(string) (*fs.FileSystemHandler, string, error) //Virtual path to File system handler resolver, must be provided externally
+}
+
+// Initialize a new media server instance
+func NewMediaServer(options *Options) *Instance {
+	return &Instance{
+		options: options,
+		VirtualPathResolver: func(s string) (*fs.FileSystemHandler, string, error) {
+			return nil, "", errors.New("no virtual path resolver assigned")
+		},
+	}
+}
+
+// Set the virtual path resolver for this media instance
+func (s *Instance) SetVirtualPathResolver(resolver func(string) (*fs.FileSystemHandler, string, error)) {
+	s.VirtualPathResolver = resolver
+}
+
+// This function validate the incoming media request and return fsh, vpath, rpath and err if any
+func (s *Instance) ValidateSourceFile(w http.ResponseWriter, r *http.Request) (*filesystem.FileSystemHandler, string, string, error) {
+	username, err := s.options.Authagent.GetUserName(w, r)
+	if err != nil {
+		return nil, "", "", errors.New("User not logged in")
+	}
+
+	userinfo, _ := s.options.UserHandler.GetUserInfoFromUsername(username)
+
+	//Validate url valid
+	if strings.Count(r.URL.String(), "?") > 1 {
+		return nil, "", "", errors.New("Invalid paramters. Multiple ? found")
+	}
+
+	targetfile, _ := utils.GetPara(r, "file")
+	targetfile, err = url.QueryUnescape(targetfile)
+	if err != nil {
+		return nil, "", "", err
+	}
+	if targetfile == "" {
+		return nil, "", "", errors.New("Missing paramter 'file'")
+	}
+
+	//Translate the virtual directory to realpath
+	fsh, subpath, err := s.VirtualPathResolver(targetfile)
+	if err != nil {
+		return nil, "", "", errors.New("Unable to load from target file system")
+	}
+	fshAbs := fsh.FileSystemAbstraction
+	realFilepath, err := fshAbs.VirtualPathToRealPath(subpath, userinfo.Username)
+	if fshAbs.FileExists(realFilepath) && fshAbs.IsDir(realFilepath) {
+		return nil, "", "", errors.New("Given path is not a file")
+	}
+	if err != nil {
+		return nil, "", "", errors.New("Unable to translate the given filepath")
+	}
+
+	if !fshAbs.FileExists(realFilepath) {
+		//Sometime if url is not URL encoded, this error might be shown as well
+
+		//Try to use manual segmentation
+
+		originalURL := r.URL.String()
+		//Must be pre-processed with system special URI Decode function to handle edge cases
+		originalURL = fs.DecodeURI(originalURL)
+		if strings.Contains(originalURL, "&download=true") {
+			originalURL = strings.ReplaceAll(originalURL, "&download=true", "")
+		} else if strings.Contains(originalURL, "download=true") {
+			originalURL = strings.ReplaceAll(originalURL, "download=true", "")
+		}
+		if strings.Contains(originalURL, "&file=") {
+			originalURL = strings.ReplaceAll(originalURL, "&file=", "file=")
+		}
+		urlInfo := strings.Split(originalURL, "file=")
+		possibleVirtualFilePath := urlInfo[len(urlInfo)-1]
+		possibleRealpath, err := fshAbs.VirtualPathToRealPath(possibleVirtualFilePath, userinfo.Username)
+		if err != nil {
+			s.options.Logger.PrintAndLog("Media Server", "Error when trying to serve file in compatibility mode", err)
+			return nil, "", "", errors.New("Error when trying to serve file in compatibility mode")
+		}
+		if fshAbs.FileExists(possibleRealpath) {
+			realFilepath = possibleRealpath
+			s.options.Logger.PrintAndLog("Media Server", "Serving file "+filepath.Base(possibleRealpath)+" in compatibility mode. Do not to use '&' or '+' sign in filename! ", nil)
+			return fsh, targetfile, realFilepath, nil
+		} else {
+			return nil, "", "", errors.New("File not exists")
+		}
+	}
+
+	return fsh, targetfile, realFilepath, nil
+}
+
+func (s *Instance) ServeMediaMime(w http.ResponseWriter, r *http.Request) {
+	targetFsh, _, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	targetFshAbs := targetFsh.FileSystemAbstraction
+	if targetFsh.RequireBuffer {
+		//File is not on local. Guess its mime by extension
+		utils.SendTextResponse(w, "application/"+filepath.Ext(realFilepath)[1:])
+		return
+	}
+
+	mime := "text/directory"
+	if !targetFshAbs.IsDir(realFilepath) {
+		m, _, err := fs.GetMime(realFilepath)
+		if err != nil {
+			mime = ""
+		}
+		mime = m
+	}
+
+	utils.SendTextResponse(w, mime)
+}
+
+// Serve any media from any file system handler to client
+func (s *Instance) ServerMedia(w http.ResponseWriter, r *http.Request) {
+	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	//Serve normal media files
+	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	targetFshAbs := targetFsh.FileSystemAbstraction
+
+	//Check if downloadMode
+	downloadMode := false
+	dw, _ := utils.GetPara(r, "download")
+	if dw == "true" {
+		downloadMode = true
+	}
+
+	//New download implementations, allow /download to be used instead of &download=true
+	if strings.Contains(r.RequestURI, "media/download/?file=") {
+		downloadMode = true
+	}
+
+	//Serve the file
+	if downloadMode {
+		escapedRealFilepath, err := url.PathUnescape(realFilepath)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		filename := filepath.Base(escapedRealFilepath)
+
+		w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
+		w.Header().Set("Content-Type", compatibility.BrowserCompatibilityOverrideContentType(r.UserAgent(), filename, r.Header.Get("Content-Type")))
+		if targetFsh.RequireBuffer || !filesystem.FileExists(realFilepath) {
+			//Stream it directly from remote
+			w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
+			remoteStream, err := targetFshAbs.ReadStream(realFilepath)
+			if err != nil {
+				utils.SendErrorResponse(w, err.Error())
+				return
+			}
+			io.Copy(w, remoteStream)
+			remoteStream.Close()
+		} else {
+			http.ServeFile(w, r, escapedRealFilepath)
+		}
+
+	} else {
+		if targetFsh.RequireBuffer {
+			w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
+			//Check buffer exists
+			ps, _ := targetFsh.GetUniquePathHash(vpath, userinfo.Username)
+			buffpool := filepath.Join(s.options.TmpDirectory, "fsbuffpool")
+			buffFile := filepath.Join(buffpool, ps)
+			if fs.FileExists(buffFile) {
+				//Stream the buff file if hash matches
+				remoteFileHash, err := s.GetHashFromRemoteFile(targetFsh.FileSystemAbstraction, realFilepath)
+				if err == nil {
+					localFileHash, err := os.ReadFile(buffFile + ".hash")
+					if err == nil {
+						if string(localFileHash) == remoteFileHash {
+							//Hash matches. Serve local buffered file
+							http.ServeFile(w, r, buffFile)
+							return
+						}
+					}
+				}
+
+			}
+
+			remoteStream, err := targetFshAbs.ReadStream(realFilepath)
+			if err != nil {
+				utils.SendErrorResponse(w, err.Error())
+				return
+			}
+			defer remoteStream.Close()
+			io.Copy(w, remoteStream)
+
+			if s.options.EnableFileBuffering {
+				os.MkdirAll(buffpool, 0775)
+				go func() {
+					s.BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath)
+				}()
+			}
+
+		} else if !filesystem.FileExists(realFilepath) {
+			//Streaming from remote file system that support fseek
+			f, err := targetFsh.FileSystemAbstraction.Open(realFilepath)
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				w.Write([]byte("500 - Internal Server Error"))
+				return
+			}
+			fstat, _ := f.Stat()
+			defer f.Close()
+			http.ServeContent(w, r, filepath.Base(realFilepath), fstat.ModTime(), f)
+		} else {
+			http.ServeFile(w, r, realFilepath)
+		}
+
+	}
+
+}
+
+// Serve video file with real-time transcoder
+func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Request) {
+	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	//Serve normal media files
+	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	resolution, err := utils.GetPara(r, "res")
+	if err != nil {
+		resolution = ""
+	}
+
+	transcodeOutputResolution := transcoder.TranscodeResolution_original
+	if resolution == "1080p" {
+		transcodeOutputResolution = transcoder.TranscodeResolution_1080p
+	} else if resolution == "720p" {
+		transcodeOutputResolution = transcoder.TranscodeResolution_720p
+	} else if resolution == "360p" {
+		transcodeOutputResolution = transcoder.TranscodeResolution_360p
+	}
+
+	targetFshAbs := targetFsh.FileSystemAbstraction
+	transcodeSourceFile := realFilepath
+	if filesystem.FileExists(transcodeSourceFile) {
+		//This is a file from the local file system.
+		//Stream it out with transcoder
+		transcodeSrcFileAbsPath, err := filepath.Abs(realFilepath)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		transcoder.TranscodeAndStream(w, r, transcodeSrcFileAbsPath, transcodeOutputResolution)
+		return
+	} else {
+		//This file is from a remote file system. Check if it already has a local buffer
+		ps, _ := targetFsh.GetUniquePathHash(vpath, userinfo.Username)
+		buffpool := filepath.Join(s.options.TmpDirectory, "fsbuffpool")
+		buffFile := filepath.Join(buffpool, ps)
+		if fs.FileExists(buffFile) {
+			//Stream the buff file if hash matches
+			remoteFileHash, err := s.GetHashFromRemoteFile(targetFsh.FileSystemAbstraction, realFilepath)
+			if err == nil {
+				localFileHash, err := os.ReadFile(buffFile + ".hash")
+				if err == nil {
+					if string(localFileHash) == remoteFileHash {
+						//Hash matches. Serve local buffered file
+						buffFileAbs, _ := filepath.Abs(buffFile)
+						transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution)
+						return
+					}
+				}
+			}
+		}
+
+		//Buffer file not exists. Buffer it to local now
+		if s.options.EnableFileBuffering {
+			os.MkdirAll(buffpool, 0775)
+			s.options.Logger.PrintAndLog("Media Server", "Buffering video from remote file system handler (might take a while)", nil)
+			err = s.BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath)
+			if err != nil {
+				utils.SendErrorResponse(w, err.Error())
+				return
+			}
+
+			//Buffer completed. Start transcode
+			buffFileAbs, _ := filepath.Abs(buffFile)
+			transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution)
+			return
+		} else {
+			utils.SendErrorResponse(w, "unable to transcode remote file with file buffer disabled")
+			return
+		}
+	}
+
+	//Check if it is a remote file system. FFmpeg can only works with local files
+	//if the file is from a remote source, buffer it to local before transcoding.
+	if targetFsh.RequireBuffer {
+		w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
+
+		remoteStream, err := targetFshAbs.ReadStream(realFilepath)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		defer remoteStream.Close()
+		io.Copy(w, remoteStream)
+
+	} else if !filesystem.FileExists(realFilepath) {
+		//Streaming from remote file system that support fseek
+		f, err := targetFsh.FileSystemAbstraction.Open(realFilepath)
+		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+			w.Write([]byte("500 - Internal Server Error"))
+			return
+		}
+		fstat, _ := f.Stat()
+		defer f.Close()
+		http.ServeContent(w, r, filepath.Base(realFilepath), fstat.ModTime(), f)
+	} else {
+		http.ServeFile(w, r, realFilepath)
+	}
+
+}
+
+func (s *Instance) BufferRemoteFileToTmp(buffFile string, fsh *filesystem.FileSystemHandler, rpath string) error {
+	if fs.FileExists(buffFile + ".download") {
+		return errors.New("another buffer process running")
+	}
+
+	//Generate a stat file for the buffer
+	hash, err := s.GetHashFromRemoteFile(fsh.FileSystemAbstraction, rpath)
+	if err != nil {
+		//Do not buffer
+		return err
+	}
+	os.WriteFile(buffFile+".hash", []byte(hash), 0775)
+
+	//Buffer the file from remote to local
+	f, err := fsh.FileSystemAbstraction.ReadStream(rpath)
+	if err != nil {
+		os.Remove(buffFile + ".hash")
+		return err
+	}
+	defer f.Close()
+
+	dest, err := os.OpenFile(buffFile+".download", os.O_CREATE|os.O_WRONLY, 0775)
+	if err != nil {
+		os.Remove(buffFile + ".hash")
+		return err
+	}
+	defer dest.Close()
+
+	io.Copy(dest, f)
+	f.Close()
+	dest.Close()
+
+	os.Rename(buffFile+".download", buffFile)
+
+	//Clean the oldest buffpool item if size too large
+	dirsize, _ := fs.GetDirctorySize(filepath.Dir(buffFile), false)
+	oldestModtime := time.Now().Unix()
+	oldestFile := ""
+	for int(dirsize) > s.options.BufferPoolSize<<20 {
+		//fmt.Println("CLEARNING BUFF", dirsize)
+		files, _ := filepath.Glob(filepath.ToSlash(filepath.Dir(buffFile)) + "/*")
+		for _, file := range files {
+			if filepath.Ext(file) == ".hash" {
+				continue
+			}
+			thisModTime, _ := fs.GetModTime(file)
+			if thisModTime < oldestModtime {
+				oldestModtime = thisModTime
+				oldestFile = file
+			}
+		}
+
+		os.Remove(oldestFile)
+		os.Remove(oldestFile + ".hash")
+
+		dirsize, _ = fs.GetDirctorySize(filepath.Dir(buffFile), false)
+		oldestModtime = time.Now().Unix()
+	}
+	return nil
+}
+
+func (s *Instance) GetHashFromRemoteFile(fshAbs filesystem.FileSystemAbstraction, rpath string) (string, error) {
+	filestat, err := fshAbs.Stat(rpath)
+	if err != nil {
+		//Always pull from remote
+		return "", err
+	}
+
+	if filestat.Size() >= int64(s.options.BufferPoolSize<<20) {
+		return "", errors.New("Unable to buffer: file larger than buffpool size")
+	}
+
+	if filestat.Size() >= int64(s.options.BufferFileMaxSize<<20) {
+		return "", errors.New("File larger than max buffer file size")
+	}
+
+	statHash := strconv.Itoa(int(filestat.ModTime().Unix() + filestat.Size()))
+	hash := md5.Sum([]byte(statHash))
+	return hex.EncodeToString(hash[:]), nil
+}

+ 92 - 0
mod/media/transcoder/transcoder.go

@@ -0,0 +1,92 @@
+package transcoder
+
+/*
+	Transcoder.go
+
+	This module handle real-time transcoding of media files
+	that is not supported by playing on web.
+*/
+
+import (
+	"io"
+	"log"
+	"net/http"
+	"os/exec"
+)
+
+type TranscodeOutputResolution string
+
+const (
+	TranscodeResolution_360p     TranscodeOutputResolution = "360p"
+	TranscodeResolution_720p     TranscodeOutputResolution = "720p"
+	TranscodeResolution_1080p    TranscodeOutputResolution = "1280p"
+	TranscodeResolution_original TranscodeOutputResolution = ""
+)
+
+// Transcode and stream the given file. Make sure ffmpeg is installed before calling to transcoder.
+func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution) {
+	// Build the FFmpeg command based on the resolution parameter
+	var cmd *exec.Cmd
+	switch resolution {
+	case "360p":
+		cmd = exec.Command("ffmpeg", "-i", inputFile, "-vf", "scale=-1:360", "-f", "mp4", "-vcodec", "libx264", "-preset", "fast", "-movflags", "frag_keyframe+empty_moov", "pipe:1")
+	case "720p":
+		cmd = exec.Command("ffmpeg", "-i", inputFile, "-vf", "scale=-1:720", "-f", "mp4", "-vcodec", "libx264", "-preset", "fast", "-movflags", "frag_keyframe+empty_moov", "pipe:1")
+	case "1080p":
+		cmd = exec.Command("ffmpeg", "-i", inputFile, "-vf", "scale=-1:1080", "-f", "mp4", "-vcodec", "libx264", "-preset", "fast", "-movflags", "frag_keyframe+empty_moov", "pipe:1")
+	case "":
+		// Original resolution
+		cmd = exec.Command("ffmpeg", "-i", inputFile, "-f", "mp4", "-vcodec", "libx264", "-preset", "fast", "-movflags", "frag_keyframe+empty_moov", "pipe:1")
+	default:
+		http.Error(w, "Invalid resolution parameter", http.StatusBadRequest)
+		return
+	}
+
+	// Set response headers for streaming MP4 video
+	w.Header().Set("Content-Type", "video/mp4")
+	w.Header().Set("Transfer-Encoding", "chunked")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Accept-Ranges", "bytes")
+
+	// Get the command output pipe
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		http.Error(w, "Failed to create output pipe", http.StatusInternalServerError)
+		return
+	}
+
+	// Get the command error pipe to capture standard error
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		http.Error(w, "Failed to create error pipe", http.StatusInternalServerError)
+		log.Printf("Failed to create error pipe: %v", err)
+		return
+	}
+
+	// Start the command
+	if err := cmd.Start(); err != nil {
+		http.Error(w, "Failed to start FFmpeg", http.StatusInternalServerError)
+		return
+	}
+
+	// Copy the command output to the HTTP response in a separate goroutine
+	go func() {
+		if _, err := io.Copy(w, stdout); err != nil {
+			//End of video
+		}
+	}()
+
+	// Read and log the command standard error
+	go func() {
+		errOutput, _ := io.ReadAll(stderr)
+		if len(errOutput) > 0 {
+			log.Printf("FFmpeg error output: %s", string(errOutput))
+		}
+	}()
+
+	// Wait for the command to finish
+	if err := cmd.Wait(); err != nil {
+		http.Error(w, "FFmpeg process failed", http.StatusInternalServerError)
+		log.Printf("FFmpeg process failed: %v", err)
+	}
+}

+ 2 - 7
web/FFmpeg Factory/init.agi

@@ -20,11 +20,6 @@ var moduleLaunchInfo = {
 	SupportedExt: [".avi",".mp4",".mp3",".aac",".flac"]
 }
 
-//Request ffmpeg for this module
-if (requirepkg("ffmpeg",true)){
-	//Register the module
-	registerModule(JSON.stringify(moduleLaunchInfo));
-}else{
-	console.log("FFMPEG not found! Not enabling FFmpeg Factory");
-}
+
+registerModule(JSON.stringify(moduleLaunchInfo));
 

+ 130 - 120
web/SystemAO/utilities/mediaPlayer.html

@@ -1,121 +1,131 @@
-<!DOCTYPE html>
-<html>
-<head>
-	<meta name="mobile-web-app-capable" content="yes">
-	<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
-	<meta charset="UTF-8">
-	<link rel="stylesheet" href="../../script/semantic/semantic.min.css">
-	<script src="../../script/jquery.min.js"></script>
-	<script src="../../script/semantic/semantic.min.js"></script>
-	<script src="../../script/ao_module.js"></script>
-	<title>ArOZ Media Player</title>
-	<style>
-		html, body{
-			height:100%;
-			width:100%;
-			padding: 0px;
-			margin:0px;
-		}
-		body{
-			background-color:rgba(10,10,10,1);
-			overflow:hidden;
-		}
-		video{
-			height:100%;
-			width:100%;
-		}
-		.videoWrapper{
-			width:100%;
-			height:calc(100%);
-		}
-		.playerMenu{
-			height:28px;
-			width: 100%;
-			padding:3px;
-			color:white;
-			position:absolute;
-
-		}
-
-		#videocover{
-			width:100%;
-			height:100%;
-			top:0px;
-			left:0px;
-			position:absolute;
-			z-index:99;
-			display:none;
-		}
-		.blur{
-			filter: blur(2px);
-		}
-	</style>
-</head>
-<body>
-	<div class="playerMenu">
-		<div style="cursor:pointer;" onclick="showMenu();">
-			<i class="large content icon"></i>
-		</div>
-	</div>
-	<dib id="videocover" onclick="hideMenu();"></dib>
-	<div class="videoWrapper">
-		<video id="player" autoplay controls></video>
-	</div>
-	<div id="mainmenu" class="ui left fixed inverted vertical menu" style="overflow-y:auto; width:220px;">
-		<div id="playerIcon" class="item">
-			<img class="ui mini middle aligned image" src="img/mediaPlayer.png" style="margin-right:12px;">
-			<span>ArOZ Media Player</span>
-		</div>
-		
-		<a class="item" onclick="hideMenu();"><i class="caret left icon"></i> Hide Menu</a>
-		</div>
-	</div>	
-	<script>
-		var fileList = ao_module_loadInputFiles();
-		var vid = document.getElementById("player");
-		var currentPlaying = 0;
-		var targetVol = 0.7;
-		if (localStorage.getItem("global_volume") !== null && localStorage.getItem("global_volume") !== undefined){
-			targetVol = localStorage.getItem("global_volume");
-		}
-		
-		if (fileList !== null){
-			playFile(fileList[0]);
-			ao_module_setWindowTitle(fileList[0].filename);
-		}
-
-		$(".file.item").remove();
-		fileList = fileList.reverse();
-		for (var i =0; i < fileList.length; i++){
-			$("#playerIcon").after(`<a class="file item" onclick="playFileByID(${i});">${fileList[i].filename}</a>`);
-
-		}
-
-		function playFileByID(id){
-			playFile(fileList[id]);
-			ao_module_setWindowTitle(fileList[id].filename);
-			hideMenu();
-		}
-
-		function showMenu(){
-			$('.menu').show();
-			$('#videocover').show();
-			$(".videoWrapper").addClass("blur");
-		}
-
-		function playFile(fileObject){
-			$("#player").attr('src',"../../media?file=" + encodeURIComponent(fileObject.filepath));
-			vid.volume = parseFloat(targetVol);
-		}
-
-		function hideMenu(){
-			$('.menu').hide();
-			$('#videocover').hide();
-			$(".videoWrapper").removeClass("blur");
-		}
-
-		hideMenu();
-		
-	</script>
-</body>
+<!DOCTYPE html>
+<html>
+<head>
+	<meta name="mobile-web-app-capable" content="yes">
+	<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
+	<meta charset="UTF-8">
+	<link rel="stylesheet" href="../../script/semantic/semantic.min.css">
+	<script src="../../script/jquery.min.js"></script>
+	<script src="../../script/semantic/semantic.min.js"></script>
+	<script src="../../script/ao_module.js"></script>
+	<title>ArOZ Media Player</title>
+	<style>
+		html, body{
+			height:100%;
+			width:100%;
+			padding: 0px;
+			margin:0px;
+		}
+		body{
+			background-color:rgba(10,10,10,1);
+			overflow:hidden;
+		}
+		video{
+			height:100%;
+			width:100%;
+		}
+		.videoWrapper{
+			width:100%;
+			height:calc(100%);
+		}
+		.playerMenu{
+			height:28px;
+			width: 100%;
+			padding:3px;
+			color:white;
+			position:absolute;
+
+		}
+
+		#videocover{
+			width:100%;
+			height:100%;
+			top:0px;
+			left:0px;
+			position:absolute;
+			z-index:99;
+			display:none;
+		}
+		.blur{
+			filter: blur(2px);
+		}
+	</style>
+</head>
+<body>
+	<div class="playerMenu">
+		<div style="cursor:pointer;" onclick="showMenu();">
+			<i class="large content icon"></i>
+		</div>
+	</div>
+	<dib id="videocover" onclick="hideMenu();"></dib>
+	<div class="videoWrapper">
+		<video id="player" autoplay controls></video>
+	</div>
+	<div id="mainmenu" class="ui left fixed inverted vertical menu" style="overflow-y:auto; width:220px;">
+		<div id="playerIcon" class="item">
+			<img class="ui mini middle aligned image" src="img/mediaPlayer.png" style="margin-right:12px;">
+			<span>ArOZ Media Player</span>
+		</div>
+		
+		<a class="item" onclick="hideMenu();"><i class="caret left icon"></i> Hide Menu</a>
+		</div>
+	</div>	
+	<script>
+		var fileList = ao_module_loadInputFiles();
+		var vid = document.getElementById("player");
+		var currentPlaying = 0;
+		var targetVol = 0.7;
+		if (localStorage.getItem("global_volume") !== null && localStorage.getItem("global_volume") !== undefined){
+			targetVol = localStorage.getItem("global_volume");
+		}
+		
+		if (fileList !== null){
+			playFile(fileList[0]);
+			ao_module_setWindowTitle(fileList[0].filename);
+		}
+
+		$(".file.item").remove();
+		fileList = fileList.reverse();
+		for (var i =0; i < fileList.length; i++){
+			$("#playerIcon").after(`<a class="file item" onclick="playFileByID(${i});">${fileList[i].filename}</a>`);
+
+		}
+
+		function playFileByID(id){
+			playFile(fileList[id]);
+			ao_module_setWindowTitle(fileList[id].filename);
+			hideMenu();
+		}
+
+		function showMenu(){
+			$('.menu').show();
+			$('#videocover').show();
+			$(".videoWrapper").addClass("blur");
+		}
+
+		function playFile(fileObject){
+			let fileExt =  fileObject.filepath.split(".").pop();
+			let videoURL = '../../media?file=' + encodeURIComponent(fileObject.filepath);
+			if (fileExt == "webm" || fileExt == "mp4" || fileExt == "ogg"){
+				//transcode
+				videoURL = '../../media?file=' + encodeURIComponent(fileObject.filepath);
+			}else{
+				//transcode
+				videoURL = '../../media/transcode?file=' + encodeURIComponent(fileObject.filepath);
+			}
+
+			$("#player").attr('src',videoURL);
+			vid.volume = parseFloat(targetVol);
+		}
+
+		function hideMenu(){
+			$('.menu').hide();
+			$('#videocover').hide();
+			$(".videoWrapper").removeClass("blur");
+		}
+
+		hideMenu();
+		
+	</script>
+</body>
 </html>

+ 9 - 1
web/Video/embedded.html

@@ -59,6 +59,14 @@
                     updatePlayerSize();
                 },500);
 
+                //Check if this is a transcode file
+                let fileExt =  playbackFile.filename.split(".").pop();
+                let videoURL = '../media?file=' + encodeURIComponent(playbackFile.filepath);
+                if (fileExt == "avi" || fileExt == "mkv" || fileExt == "rmvb"){
+                    //transcode
+                    videoURL = '../media/transcode?file=' + encodeURIComponent(playbackFile.filepath);
+                }
+
                  //Set player property
                 const dp = new DPlayer({
                     container: document.getElementById('dplayer'),
@@ -66,7 +74,7 @@
                     autoplay: true,
                     volume: parseFloat(defaultVol),
                     video: {
-                        url: '../media?file=' + encodeURIComponent(playbackFile.filepath)
+                        url: videoURL
                     },
                     contextmenu: [
                         {

+ 1 - 1
web/Video/init.agi

@@ -16,7 +16,7 @@ var moduleLaunchInfo = {
 	LaunchEmb: "Video/embedded.html",
 	InitFWSize: [585, 840],
 	InitEmbSize: [700, 424],
-	SupportedExt: [".webm",".mp4",".ogg"]
+	SupportedExt: [".webm",".mp4",".ogg", ".rmvb", ".mkv", ".avi"]
 }
 
 //Register the module