diff --git a/httphandlers/httphandlers.go b/httphandlers/httphandlers.go index b1ee553..55be40d 100644 --- a/httphandlers/httphandlers.go +++ b/httphandlers/httphandlers.go @@ -252,11 +252,11 @@ func serveContent(w http.ResponseWriter, r *http.Request, tv *soapcalls.TVPayloa switch f := mf.(type) { case osFileType: - serveContentCustomType(w, r, mediaType, transcode, seek, f, ff) + serveContentCustomType(w, r, tv, mediaType, transcode, seek, f, ff) case []byte: serveContentBytes(w, r, mediaType, f) case io.ReadCloser: - serveContentReadClose(w, r, mediaType, transcode, f, ff) + serveContentReadClose(w, r, tv, mediaType, transcode, f, ff) default: http.NotFound(w, r) return @@ -279,7 +279,7 @@ func serveContentBytes(w http.ResponseWriter, r *http.Request, mediaType string, http.ServeContent(w, r, name, time.Now(), bReader) } -func serveContentReadClose(w http.ResponseWriter, r *http.Request, mediaType string, transcode bool, f io.ReadCloser, ff *exec.Cmd) { +func serveContentReadClose(w http.ResponseWriter, r *http.Request, tv *soapcalls.TVPayload, mediaType string, transcode bool, f io.ReadCloser, ff *exec.Cmd) { if r.Header.Get("getcontentFeatures.dlna.org") == "1" { contentFeatures, err := utils.BuildContentFeatures(mediaType, "00", transcode) if err != nil { @@ -293,7 +293,7 @@ func serveContentReadClose(w http.ResponseWriter, r *http.Request, mediaType str // Since we're dealing with an io.Reader we can't // allow any HEAD requests that some DMRs trigger. if transcode && r.Method == http.MethodGet && strings.Contains(mediaType, "video") { - _ = utils.ServeTranscodedStream(w, f, ff) + _ = utils.ServeTranscodedStream(w, f, ff, tv.FFmpegPath) return } @@ -305,7 +305,7 @@ func serveContentReadClose(w http.ResponseWriter, r *http.Request, mediaType str } } -func serveContentCustomType(w http.ResponseWriter, r *http.Request, mediaType string, transcode, seek bool, f osFileType, ff *exec.Cmd) { +func serveContentCustomType(w http.ResponseWriter, r *http.Request, tv *soapcalls.TVPayload, mediaType string, transcode, seek bool, f osFileType, ff *exec.Cmd) { if r.Header.Get("getcontentFeatures.dlna.org") == "1" { seekflag := "00" if seek { @@ -330,7 +330,7 @@ func serveContentCustomType(w http.ResponseWriter, r *http.Request, mediaType st if f.path != "" { input = f.path } - _ = utils.ServeTranscodedStream(w, input, ff) + _ = utils.ServeTranscodedStream(w, input, ff, tv.FFmpegPath) return } diff --git a/internal/gui/actions.go b/internal/gui/actions.go index 0a006e5..e508d98 100644 --- a/internal/gui/actions.go +++ b/internal/gui/actions.go @@ -100,8 +100,7 @@ func selectMediaFile(screen *NewScreen, f fyne.URI) { screen.MediaText.Refresh() - subs, err := utils.GetSubs(absMediaFile) - check(screen, err) + subs, err := utils.GetSubs(screen.ffmpegPath, absMediaFile) if err != nil { screen.SelectInternalSubs.Options = []string{} screen.SelectInternalSubs.PlaceHolder = "No Embedded Subs" @@ -344,7 +343,7 @@ func playAction(screen *NewScreen) { if opt == screen.SelectInternalSubs.Selected { screen.PlayPause.Text = "Extracting Subtitles" screen.PlayPause.Refresh() - tempSubsPath, err := utils.ExtractSub(n, screen.mediafile) + tempSubsPath, err := utils.ExtractSub(screen.ffmpegPath, n, screen.mediafile) screen.PlayPause.Text = "Play" screen.PlayPause.Refresh() if err != nil { @@ -373,6 +372,7 @@ func playAction(screen *NewScreen) { Transcode: screen.Transcode, Seekable: isSeek, LogOutput: screen.Debug, + FFmpegPath: screen.ffmpegPath, } screen.httpserver = httphandlers.NewServer(whereToListen) diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 0258b73..e2aa0a1 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -21,6 +21,7 @@ import ( "fyne.io/fyne/v2/widget" "github.com/alexballas/go2tv/httphandlers" "github.com/alexballas/go2tv/soapcalls" + "github.com/alexballas/go2tv/soapcalls/utils" ) // NewScreen . @@ -41,6 +42,7 @@ type NewScreen struct { SubsText *widget.Entry CustomSubsCheck *widget.Check NextMediaCheck *widget.Check + TranscodeCheckBox *widget.Check Stop *widget.Button DeviceList *deviceList httpserver *httphandlers.HTTPserver @@ -60,6 +62,7 @@ type NewScreen struct { renderingControlURL string connectionManagerURL string currentmfolder string + ffmpegPath string mediaFormats []string muError sync.RWMutex mu sync.RWMutex @@ -113,6 +116,11 @@ func Start(ctx context.Context, s *NewScreen) { s.Hotkeys = true tabs.OnSelected = func(t *container.TabItem) { + s.TranscodeCheckBox.Enable() + if err := utils.CheckFFmpeg(s.ffmpegPath); err != nil { + s.TranscodeCheckBox.Disable() + } + if t.Text == "Go2TV" { s.Hotkeys = true return @@ -120,6 +128,10 @@ func Start(ctx context.Context, s *NewScreen) { s.Hotkeys = false } + if err := utils.CheckFFmpeg(s.ffmpegPath); err != nil { + s.TranscodeCheckBox.Disable() + } + s.tabs = tabs w.SetContent(tabs) diff --git a/internal/gui/main.go b/internal/gui/main.go index 349a313..53454d7 100644 --- a/internal/gui/main.go +++ b/internal/gui/main.go @@ -8,7 +8,6 @@ import ( "errors" "math" "net/url" - "os/exec" "sort" "sync" "time" @@ -325,11 +324,6 @@ func mainWindow(s *NewScreen) fyne.CanvasObject { nextmedia := widget.NewCheck("Auto-Play Next File", func(b bool) {}) transcode := widget.NewCheck("Transcode", func(b bool) {}) - _, err := exec.LookPath("ffmpeg") - if err != nil { - transcode.Disable() - } - mediafilelabel := canvas.NewText("File:", nil) subsfilelabel := canvas.NewText("Subtitles:", nil) devicelabel := canvas.NewText("Select Device:", nil) @@ -367,6 +361,7 @@ func mainWindow(s *NewScreen) fyne.CanvasObject { s.CurrentPos = curPos s.EndPos = endPos s.SelectInternalSubs = selectInternalSubs + s.TranscodeCheckBox = transcode curPos.Set("00:00:00") endPos.Set("00:00:00") @@ -476,7 +471,7 @@ func mainWindow(s *NewScreen) fyne.CanvasObject { s.MediaText.Disable() if mediafileOld != "" { - subs, err := utils.GetSubs(mediafileOld) + subs, err := utils.GetSubs(s.ffmpegPath, mediafileOld) if err != nil { s.SelectInternalSubs.Options = []string{} s.SelectInternalSubs.PlaceHolder = "No Embedded Subs" @@ -531,7 +526,7 @@ func mainWindow(s *NewScreen) fyne.CanvasObject { } if s.tvdata != nil && s.tvdata.CallbackURL != "" { - _, err = queueNext(s, true) + _, err := queueNext(s, true) if err != nil { stopAction(s) } diff --git a/internal/gui/settings.go b/internal/gui/settings.go index 15cd312..d14aa95 100644 --- a/internal/gui/settings.go +++ b/internal/gui/settings.go @@ -4,6 +4,7 @@ package gui import ( + "runtime" "time" "fyne.io/fyne/v2" @@ -43,6 +44,35 @@ func settingsWindow(s *NewScreen) fyne.CanvasObject { } } + ffmpegText := widget.NewLabel("ffmpeg Path") + ffmpegTextEntry := widget.NewEntry() + + ffmpegTextEntry.Text = func() string { + if fyne.CurrentApp().Preferences().String("ffmpeg") != "" { + return fyne.CurrentApp().Preferences().String("ffmpeg") + } + + os := runtime.GOOS + switch os { + case "windows": + return "ffmpeg" + case "linux": + return "ffmpeg" + case "darwin": + return "/opt/homebrew/bin/ffmpeg" + default: + return "ffmpeg" + } + + }() + ffmpegTextEntry.Refresh() + s.ffmpegPath = ffmpegTextEntry.Text + + ffmpegTextEntry.OnChanged = func(update string) { + s.ffmpegPath = update + fyne.CurrentApp().Preferences().SetString("ffmpeg", update) + } + debugText := widget.NewLabel("Debug") debugExport := widget.NewButton("Export Debug Logs", func() { var itemInRing bool @@ -107,7 +137,7 @@ If 'Auto-Play Next File' is not working correctly, please disable it.`, w) dropdown.Refresh() - return container.New(layout.NewFormLayout(), themeText, dropdown, gaplessText, gaplessdropdown, debugText, debugExport) + return container.New(layout.NewFormLayout(), themeText, dropdown, gaplessText, gaplessdropdown, ffmpegText, ffmpegTextEntry, debugText, debugExport) } func saveDebugLogs(f fyne.URIWriteCloser, s *NewScreen) { diff --git a/soapcalls/soapbuilders.go b/soapcalls/soapbuilders.go index e474b7a..fd97484 100644 --- a/soapcalls/soapbuilders.go +++ b/soapcalls/soapbuilders.go @@ -369,7 +369,7 @@ func setAVTransportSoapBuild(tvdata *TVPayload) ([]byte, error) { var didl didLLiteItem resNodeData := []resNode{} - duration, _ := utils.DurationForMedia(tvdata.MediaPath) + duration, _ := utils.DurationForMedia(tvdata.FFmpegPath, tvdata.MediaPath) switch duration { case "": @@ -512,7 +512,7 @@ func setNextAVTransportSoapBuild(tvdata *TVPayload, clear bool) ([]byte, error) var didl didLLiteItem resNodeData := []resNode{} - duration, _ := utils.DurationForMedia(tvdata.MediaPath) + duration, _ := utils.DurationForMedia(tvdata.FFmpegPath, tvdata.MediaPath) switch duration { case "": diff --git a/soapcalls/soapcallers.go b/soapcalls/soapcallers.go index a36854b..e47e835 100644 --- a/soapcalls/soapcallers.go +++ b/soapcalls/soapcallers.go @@ -40,6 +40,7 @@ type TVPayload struct { CurrentTimers map[string]*time.Timer InitialMediaRenderersStates map[string]bool MediaRenderersStates map[string]*States + FFmpegPath string EventURL string ControlURL string MediaURL string diff --git a/soapcalls/utils/checkffmpeg.go b/soapcalls/utils/checkffmpeg.go new file mode 100644 index 0000000..2bb787d --- /dev/null +++ b/soapcalls/utils/checkffmpeg.go @@ -0,0 +1,15 @@ +//go:build !windows +// +build !windows + +package utils + +import "os/exec" + +func CheckFFmpeg(ffmpeg string) error { + checkffmpeg := exec.Command(ffmpeg, "-h") + _, err := checkffmpeg.Output() + if err != nil { + return err + } + return nil +} diff --git a/soapcalls/utils/checkffmpeg_windows.go b/soapcalls/utils/checkffmpeg_windows.go new file mode 100644 index 0000000..ae5592e --- /dev/null +++ b/soapcalls/utils/checkffmpeg_windows.go @@ -0,0 +1,17 @@ +package utils + +import ( + "os/exec" + "syscall" +) + +func CheckFFmpeg(ffmpeg string) error { + checkffmpeg := exec.Command(ffmpeg, "-h") + checkffmpeg.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + + _, err := checkffmpeg.Output() + if err != nil { + return err + } + return nil +} diff --git a/soapcalls/utils/ffprobe.go b/soapcalls/utils/ffprobe.go index 232ca1f..7eaa9e3 100644 --- a/soapcalls/utils/ffprobe.go +++ b/soapcalls/utils/ffprobe.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" "time" ) @@ -18,14 +19,18 @@ type ffprobeInfo struct { } `json:"format"` } -func DurationForMedia(f string) (string, error) { +func DurationForMedia(ffmpeg string, f string) (string, error) { _, err := os.Stat(f) if err != nil { return "", err } + if err := CheckFFmpeg(ffmpeg); err != nil { + return "", err + } + cmd := exec.Command( - "ffprobe", + filepath.Join(filepath.Dir(ffmpeg), "ffprobe"), "-loglevel", "error", "-show_format", "-of", "json", diff --git a/soapcalls/utils/ffprobe_windows.go b/soapcalls/utils/ffprobe_windows.go index 8b06ec1..315da69 100644 --- a/soapcalls/utils/ffprobe_windows.go +++ b/soapcalls/utils/ffprobe_windows.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" "syscall" "time" @@ -16,14 +17,18 @@ type ffprobeInfo struct { } `json:"format"` } -func DurationForMedia(f string) (string, error) { +func DurationForMedia(ffmpeg string, f string) (string, error) { _, err := os.Stat(f) if err != nil { return "", err } + if err := CheckFFmpeg(ffmpeg); err != nil { + return "", err + } + cmd := exec.Command( - "ffprobe", + filepath.Join(filepath.Dir(ffmpeg), "ffprobe"), "-loglevel", "error", "-show_format", "-of", "json", diff --git a/soapcalls/utils/substools.go b/soapcalls/utils/substools.go index 95a7381..e82e4ef 100644 --- a/soapcalls/utils/substools.go +++ b/soapcalls/utils/substools.go @@ -1,11 +1,14 @@ +//go:build !windows +// +build !windows + package utils import ( "encoding/json" "errors" - "fmt" "os" "os/exec" + "path/filepath" "strconv" "github.com/mitchellh/mapstructure" @@ -28,15 +31,20 @@ type tags struct { var ErrNoSubs = errors.New("no subs") -func GetSubs(f string) ([]string, error) { +func GetSubs(ffmpeg string, f string) ([]string, error) { _, err := os.Stat(f) if err != nil { - fmt.Println(err) + return nil, err + } + + // We assume the ffprobe path based on the ffmpeg one. + // So we need to ensure that the ffmpeg one exists. + if err := CheckFFmpeg(ffmpeg); err != nil { return nil, err } cmd := exec.Command( - "ffprobe", + filepath.Join(filepath.Dir(ffmpeg), "ffprobe"), "-loglevel", "error", "-show_streams", "-of", "json", @@ -45,14 +53,12 @@ func GetSubs(f string) ([]string, error) { output, err := cmd.Output() if err != nil { - fmt.Println(err) return nil, err } var info ffprobeInfoforSubs if err := json.Unmarshal(output, &info); err != nil { - fmt.Println(err) return nil, err } @@ -64,7 +70,6 @@ func GetSubs(f string) ([]string, error) { subcounter++ tag := &tags{} if err := mapstructure.Decode(s.Tags, tag); err != nil { - fmt.Println(err) return nil, err } @@ -82,14 +87,13 @@ func GetSubs(f string) ([]string, error) { } if len(out) == 0 { - fmt.Println(ErrNoSubs) return nil, ErrNoSubs } return out, nil } -func ExtractSub(n int, f string) (string, error) { +func ExtractSub(ffmpeg string, n int, f string) (string, error) { _, err := os.Stat(f) if err != nil { return "", err @@ -101,7 +105,7 @@ func ExtractSub(n int, f string) (string, error) { } cmd := exec.Command( - "ffmpeg", + ffmpeg, "-y", "-i", f, "-map", "0:s:"+strconv.Itoa(n), diff --git a/soapcalls/utils/substools_windows.go b/soapcalls/utils/substools_windows.go new file mode 100644 index 0000000..c913f1c --- /dev/null +++ b/soapcalls/utils/substools_windows.go @@ -0,0 +1,119 @@ +package utils + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + + "github.com/mitchellh/mapstructure" +) + +type ffprobeInfoforSubs struct { + Streams []streams `json:"streams"` +} + +type streams struct { + Tags any `json:"tags,omitempty"` + CodecType string `json:"codec_type"` + Index int `json:"index"` +} + +type tags struct { + Title string `mapstructure:"title"` + Language string `mapstructure:"language"` +} + +var ErrNoSubs = errors.New("no subs") + +func GetSubs(ffmpeg string, f string) ([]string, error) { + _, err := os.Stat(f) + if err != nil { + return nil, err + } + + if err := CheckFFmpeg(ffmpeg); err != nil { + return nil, err + } + + cmd := exec.Command( + filepath.Join(filepath.Dir(ffmpeg), "ffprobe"), + "-loglevel", "error", + "-show_streams", + "-of", "json", + f, + ) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var info ffprobeInfoforSubs + + if err := json.Unmarshal(output, &info); err != nil { + return nil, err + } + + out := make([]string, 0) + + var subcounter int + for _, s := range info.Streams { + if s.CodecType == "subtitle" { + subcounter++ + tag := &tags{} + if err := mapstructure.Decode(s.Tags, tag); err != nil { + return nil, err + } + + subName := tag.Title + if tag.Title == "" { + subName = tag.Language + } + + if subName == "" { + subName = strconv.Itoa(subcounter) + } + + out = append(out, subName) + } + } + + if len(out) == 0 { + return nil, ErrNoSubs + } + + return out, nil +} + +func ExtractSub(ffmpeg string, n int, f string) (string, error) { + _, err := os.Stat(f) + if err != nil { + return "", err + } + + tempSub, err := os.CreateTemp(os.TempDir(), "go2tv-sub-*.srt") + if err != nil { + return "", err + } + + cmd := exec.Command( + ffmpeg, + "-y", + "-i", f, + "-map", "0:s:"+strconv.Itoa(n), + tempSub.Name(), + ) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + + _, err = cmd.Output() + if err != nil { + return "", err + } + + return tempSub.Name(), nil +} diff --git a/soapcalls/utils/transcode.go b/soapcalls/utils/transcode.go index 6ee734c..18c7d37 100644 --- a/soapcalls/utils/transcode.go +++ b/soapcalls/utils/transcode.go @@ -15,7 +15,7 @@ var ( // ServeTranscodedStream passes an input file or io.Reader to ffmpeg and writes the output directly // to our io.Writer. -func ServeTranscodedStream(w io.Writer, input interface{}, ff *exec.Cmd) error { +func ServeTranscodedStream(w io.Writer, input interface{}, ff *exec.Cmd, ffmpeg string) error { // Pipe streaming is not great as explained here // https://video.stackexchange.com/questions/34087/ffmpeg-fails-on-pipe-to-pipe-video-decoding. // That's why if we have the option to pass the file directly to ffmpeg, we should. @@ -34,7 +34,7 @@ func ServeTranscodedStream(w io.Writer, input interface{}, ff *exec.Cmd) error { } cmd := exec.Command( - "ffmpeg", + ffmpeg, "-re", "-i", in, "-vcodec", "h264", diff --git a/soapcalls/utils/transcode_windows.go b/soapcalls/utils/transcode_windows.go index 3e58d91..7686f2d 100644 --- a/soapcalls/utils/transcode_windows.go +++ b/soapcalls/utils/transcode_windows.go @@ -13,7 +13,7 @@ var ( // ServeTranscodedStream passes an input file or io.Reader to ffmpeg and writes the output directly // to our io.Writer. -func ServeTranscodedStream(w io.Writer, input interface{}, ff *exec.Cmd) error { +func ServeTranscodedStream(w io.Writer, input interface{}, ff *exec.Cmd, ffmpeg string) error { // Pipe streaming is not great as explained here // https://video.stackexchange.com/questions/34087/ffmpeg-fails-on-pipe-to-pipe-video-decoding. // That's why if we have the option to pass the file directly to ffmpeg, we should. @@ -32,7 +32,7 @@ func ServeTranscodedStream(w io.Writer, input interface{}, ff *exec.Cmd) error { } cmd := exec.Command( - "ffmpeg", + ffmpeg, "-re", "-i", in, "-vcodec", "h264",