transcoder.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. package transcoder
  2. /*
  3. Transcoder.go
  4. This module handle real-time transcoding of media files
  5. that is not supported by playing on web.
  6. */
  7. import (
  8. "encoding/json"
  9. "fmt"
  10. "io"
  11. "net/http"
  12. "os/exec"
  13. "strconv"
  14. "time"
  15. "imuslab.com/arozos/mod/info/logger"
  16. )
  17. type TranscodeOutputResolution string
  18. const (
  19. TranscodeResolution_360p TranscodeOutputResolution = "360p"
  20. TranscodeResolution_720p TranscodeOutputResolution = "720p"
  21. TranscodeResolution_1080p TranscodeOutputResolution = "1280p"
  22. TranscodeResolution_original TranscodeOutputResolution = ""
  23. )
  24. // Transcode and stream the given file. Make sure ffmpeg is installed before calling to transcoder.
  25. // startTime is a seek offset in seconds; pass 0 to start from the beginning.
  26. func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution, startTime float64) {
  27. // Build the FFmpeg command based on the resolution parameter
  28. var cmd *exec.Cmd
  29. transcodeFormatArgs := []string{"-f", "mp4", "-vcodec", "libx264", "-preset", "superfast", "-g", "60", "-movflags", "frag_keyframe+empty_moov+faststart", "pipe:1"}
  30. var preInputArgs []string
  31. if startTime > 0.001 {
  32. preInputArgs = []string{"-ss", fmt.Sprintf("%.3f", startTime)}
  33. }
  34. var middleArgs []string
  35. switch resolution {
  36. case "360p":
  37. middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:360"}
  38. case "720p":
  39. middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:720"}
  40. case "1080p":
  41. middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:1080"}
  42. case "":
  43. middleArgs = []string{"-i", inputFile}
  44. default:
  45. http.Error(w, "Invalid resolution parameter", http.StatusBadRequest)
  46. return
  47. }
  48. var args []string
  49. args = append(args, preInputArgs...)
  50. args = append(args, middleArgs...)
  51. args = append(args, transcodeFormatArgs...)
  52. cmd = exec.Command("ffmpeg", args...)
  53. // Set response headers for streaming MP4 video
  54. w.Header().Set("Content-Type", "video/mp4")
  55. w.Header().Set("Transfer-Encoding", "chunked")
  56. w.Header().Set("Cache-Control", "public, max-age=3600, s-maxage=3600, must-revalidate")
  57. w.Header().Set("Accept-Ranges", "bytes")
  58. // Get the command output pipe
  59. stdout, err := cmd.StdoutPipe()
  60. if err != nil {
  61. http.Error(w, "Failed to create output pipe", http.StatusInternalServerError)
  62. return
  63. }
  64. // Get the command error pipe to capture standard error
  65. stderr, err := cmd.StderrPipe()
  66. if err != nil {
  67. http.Error(w, "Failed to create error pipe", http.StatusInternalServerError)
  68. logger.PrintAndLog("Transcoder", fmt.Sprintf("Failed to create error pipe: %v", err), nil)
  69. return
  70. }
  71. // Start the command
  72. if err := cmd.Start(); err != nil {
  73. http.Error(w, "Failed to start FFmpeg", http.StatusInternalServerError)
  74. return
  75. }
  76. // Buffered so both the natural-end goroutine and the client-disconnect goroutine
  77. // can send without blocking — only the first signal is consumed.
  78. done := make(chan struct{}, 2)
  79. // Monitor client connection close
  80. go func() {
  81. <-r.Context().Done()
  82. time.Sleep(300 * time.Millisecond)
  83. cmd.Process.Kill()
  84. done <- struct{}{}
  85. }()
  86. // Copy the command output to the HTTP response in a separate goroutine
  87. go func() {
  88. if _, err := io.Copy(w, stdout); err != nil {
  89. cmd.Process.Kill()
  90. }
  91. // Signal natural end so the handler returns and the chunked-transfer
  92. // terminator is flushed to the client.
  93. done <- struct{}{}
  94. }()
  95. // Read and log the command standard error
  96. go func() {
  97. errOutput, _ := io.ReadAll(stderr)
  98. if len(errOutput) > 0 {
  99. logger.PrintAndLog("Transcoder", fmt.Sprintf("FFmpeg error output: %s", string(errOutput)), nil)
  100. }
  101. }()
  102. go func() {
  103. if err := cmd.Wait(); err != nil {
  104. logger.PrintAndLog("Transcoder", fmt.Sprintf("FFmpeg process exited: %v", err), nil)
  105. return
  106. }
  107. }()
  108. // Wait for the command to finish or client disconnect
  109. <-done
  110. logger.PrintAndLog("Transcoder", "[Media Server] Transcode client disconnected", nil)
  111. }
  112. type TranscodeAudioSampleRate int
  113. const (
  114. TranscodeAudio_16kHz TranscodeAudioSampleRate = 16000
  115. TranscodeAudio_24kHz TranscodeAudioSampleRate = 24000
  116. TranscodeAudio_48kHz TranscodeAudioSampleRate = 48000
  117. )
  118. // TranscodeAndStreamAudio transcodes an audio file to MP3 and streams it.
  119. // startTime is the seek offset in seconds; pass 0 to start from the beginning.
  120. func TranscodeAndStreamAudio(w http.ResponseWriter, r *http.Request, inputFile string, sampleRate TranscodeAudioSampleRate, startTime float64) {
  121. var args []string
  122. if startTime > 0.001 {
  123. args = append(args, "-ss", fmt.Sprintf("%.3f", startTime))
  124. }
  125. args = append(args,
  126. "-i", inputFile,
  127. "-vn",
  128. "-acodec", "libmp3lame",
  129. "-ar", fmt.Sprintf("%d", int(sampleRate)),
  130. "-b:a", "128k",
  131. "-f", "mp3",
  132. "pipe:1",
  133. )
  134. cmd := exec.Command("ffmpeg", args...)
  135. w.Header().Set("Content-Type", "audio/mpeg")
  136. w.Header().Set("Transfer-Encoding", "chunked")
  137. w.Header().Set("Cache-Control", "no-cache, no-store")
  138. w.Header().Set("X-Content-Type-Options", "nosniff")
  139. stdout, err := cmd.StdoutPipe()
  140. if err != nil {
  141. http.Error(w, "Failed to create output pipe", http.StatusInternalServerError)
  142. return
  143. }
  144. stderr, err := cmd.StderrPipe()
  145. if err != nil {
  146. http.Error(w, "Failed to create error pipe", http.StatusInternalServerError)
  147. return
  148. }
  149. if err := cmd.Start(); err != nil {
  150. http.Error(w, "Failed to start FFmpeg", http.StatusInternalServerError)
  151. return
  152. }
  153. // Buffered so both the natural-end goroutine and the client-disconnect goroutine
  154. // can send without blocking — only the first signal is consumed.
  155. done := make(chan struct{}, 2)
  156. go func() {
  157. <-r.Context().Done()
  158. time.Sleep(300 * time.Millisecond)
  159. cmd.Process.Kill()
  160. done <- struct{}{}
  161. }()
  162. go func() {
  163. if _, err := io.Copy(w, stdout); err != nil {
  164. cmd.Process.Kill()
  165. }
  166. // Signal even on a clean finish so the handler returns and the HTTP
  167. // chunked-transfer terminator (final zero-length frame) is flushed to
  168. // the client. Without this the browser never receives EOF and the
  169. // audio element's 'ended' event does not fire reliably.
  170. done <- struct{}{}
  171. }()
  172. go func() {
  173. errOutput, _ := io.ReadAll(stderr)
  174. if len(errOutput) > 0 {
  175. logger.PrintAndLog("Transcoder", fmt.Sprintf("FFmpeg audio error output: %s", string(errOutput)), nil)
  176. }
  177. }()
  178. go func() {
  179. if err := cmd.Wait(); err != nil {
  180. logger.PrintAndLog("Transcoder", fmt.Sprintf("FFmpeg audio process exited: %v", err), nil)
  181. }
  182. }()
  183. <-done
  184. logger.PrintAndLog("Transcoder", "[Media Server] Audio transcode client disconnected", nil)
  185. }
  186. // GetAudioDuration returns the duration of a local audio file in seconds using ffprobe.
  187. func GetAudioDuration(inputFile string) (float64, error) {
  188. cmd := exec.Command("ffprobe",
  189. "-v", "quiet",
  190. "-print_format", "json",
  191. "-show_format",
  192. inputFile,
  193. )
  194. output, err := cmd.Output()
  195. if err != nil {
  196. return 0, fmt.Errorf("ffprobe failed: %w", err)
  197. }
  198. var result struct {
  199. Format struct {
  200. Duration string `json:"duration"`
  201. } `json:"format"`
  202. }
  203. if err := json.Unmarshal(output, &result); err != nil {
  204. return 0, fmt.Errorf("failed to parse ffprobe output: %w", err)
  205. }
  206. duration, err := strconv.ParseFloat(result.Format.Duration, 64)
  207. if err != nil {
  208. return 0, fmt.Errorf("invalid duration value: %w", err)
  209. }
  210. return duration, nil
  211. }