mirror of
https://github.com/IllusionMan1212/ffui.git
synced 2026-10-24 18:12:35 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96389610ed
|
||
|
|
7b03fa0d86
|
||
|
|
039e30bacc
|
||
|
|
76ef469443
|
@@ -1,4 +1,2 @@
|
|||||||
## TODO: Don't show incompatible encoding options for encoders
|
|
||||||
## TODO: send a SIGINT to the ffmpeg process on ctrl+c
|
|
||||||
## TODO: calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage.
|
## TODO: calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage.
|
||||||
## TODO: when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded
|
## TODO: when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -36,9 +37,12 @@ type model struct {
|
|||||||
TotalProgress float64
|
TotalProgress float64
|
||||||
Estimate int
|
Estimate int
|
||||||
Program *tea.Program
|
Program *tea.Program
|
||||||
|
Command *exec.Cmd
|
||||||
Quitting bool
|
Quitting bool
|
||||||
|
Cancelled bool
|
||||||
Screen Screen
|
Screen Screen
|
||||||
Config map[int]Config
|
Config []Config
|
||||||
|
VisibleConfig []Config
|
||||||
FocusIndex int
|
FocusIndex int
|
||||||
ParsedConfig ParsedConfig
|
ParsedConfig ParsedConfig
|
||||||
DryRun bool
|
DryRun bool
|
||||||
@@ -51,12 +55,6 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
|
|||||||
s.Spinner = spinner.Dot
|
s.Spinner = spinner.Dot
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||||
|
|
||||||
cfg := make(map[int]Config)
|
|
||||||
|
|
||||||
for i := 0; i < len(Configs); i++ {
|
|
||||||
cfg[i] = Configs[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model{
|
return &model{
|
||||||
IsDirectory: fileInfo.IsDir(),
|
IsDirectory: fileInfo.IsDir(),
|
||||||
CurrentFileName: fileInfo.Name(),
|
CurrentFileName: fileInfo.Name(),
|
||||||
@@ -69,12 +67,23 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
|
|||||||
TotalProgressBar: progress.New(progress.WithDefaultGradient()),
|
TotalProgressBar: progress.New(progress.WithDefaultGradient()),
|
||||||
TotalProgress: 0.0,
|
TotalProgress: 0.0,
|
||||||
Quitting: false,
|
Quitting: false,
|
||||||
Config: cfg,
|
Config: Configs,
|
||||||
|
VisibleConfig: getVisibleConfigs(Configs),
|
||||||
ErrQuit: false,
|
ErrQuit: false,
|
||||||
ErrQuitMessage: "",
|
ErrQuitMessage: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) updateConfigFocusedOptions() {
|
||||||
|
for _, visCfg := range m.VisibleConfig {
|
||||||
|
for i := range m.Config {
|
||||||
|
if m.Config[i].Name == visCfg.Name {
|
||||||
|
m.Config[i].FocusedOption = visCfg.FocusedOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
return tea.Batch(m.Spinner.Tick, initCfg)
|
return tea.Batch(m.Spinner.Tick, initCfg)
|
||||||
}
|
}
|
||||||
@@ -83,12 +92,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "ctrl+c", "esc":
|
|
||||||
// TODO: cancel the ffmpeg command context to terminate the process
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case errQuitMsg:
|
case errQuitMsg:
|
||||||
m.ErrQuitMessage = msg.msg
|
m.ErrQuitMessage = msg.msg
|
||||||
m.ErrQuit = true
|
m.ErrQuit = true
|
||||||
@@ -101,30 +104,38 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
switch key {
|
switch key {
|
||||||
|
case "ctrl+c", "esc":
|
||||||
|
return m, tea.Quit
|
||||||
case "enter", " ":
|
case "enter", " ":
|
||||||
// parse config and switch to main screen if we're focused on the start button
|
// parse config and switch to main screen if we're focused on the start button
|
||||||
if m.FocusIndex == len(Configs) {
|
if m.FocusIndex == len(m.VisibleConfig) {
|
||||||
return m, m.parseConfig(false)
|
return m, m.parseConfig(false)
|
||||||
}
|
}
|
||||||
// parse config, print it and exit
|
// parse config, print it and exit
|
||||||
if m.FocusIndex == len(Configs)+1 {
|
if m.FocusIndex == len(m.VisibleConfig)+1 {
|
||||||
return m, m.parseConfig(true)
|
return m, m.parseConfig(true)
|
||||||
}
|
}
|
||||||
case "tab", "shift+tab", "up", "down":
|
case "g":
|
||||||
if key == "up" || key == "shift+tab" {
|
m.FocusIndex = 0
|
||||||
|
case "G":
|
||||||
|
m.FocusIndex = len(m.VisibleConfig) + 1
|
||||||
|
case "tab", "shift+tab", "up", "down", "j", "k":
|
||||||
|
if key == "up" || key == "shift+tab" || key == "k" {
|
||||||
m.FocusIndex--
|
m.FocusIndex--
|
||||||
} else {
|
} else {
|
||||||
m.FocusIndex++
|
m.FocusIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.FocusIndex >= len(Configs)+2 {
|
if m.FocusIndex >= len(m.VisibleConfig)+2 {
|
||||||
m.FocusIndex = 0
|
m.FocusIndex = 0
|
||||||
} else if m.FocusIndex < 0 {
|
} else if m.FocusIndex < 0 {
|
||||||
m.FocusIndex = len(Configs) + 1
|
m.FocusIndex = len(m.VisibleConfig) + 1
|
||||||
}
|
}
|
||||||
case "left", "right":
|
case "left", "right", "h", "l":
|
||||||
if cfg, ok := m.Config[m.FocusIndex]; ok {
|
// If we're not hovering a button
|
||||||
if key == "right" {
|
if m.FocusIndex < len(m.VisibleConfig) {
|
||||||
|
cfg := &m.VisibleConfig[m.FocusIndex]
|
||||||
|
if key == "right" || key == "l" {
|
||||||
cfg.FocusedOption++
|
cfg.FocusedOption++
|
||||||
} else {
|
} else {
|
||||||
cfg.FocusedOption--
|
cfg.FocusedOption--
|
||||||
@@ -136,7 +147,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cfg.FocusedOption = len(cfg.Opts) - 1
|
cfg.FocusedOption = len(cfg.Opts) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Config[m.FocusIndex] = cfg
|
m.updateConfigFocusedOptions()
|
||||||
|
m.VisibleConfig = getVisibleConfigs(m.Config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case quitMsg:
|
case quitMsg:
|
||||||
@@ -149,6 +161,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case Main:
|
case Main:
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
key := msg.String()
|
||||||
|
switch key {
|
||||||
|
case "ctrl+c":
|
||||||
|
m.Cancelled = true
|
||||||
|
err := m.Command.Process.Signal(os.Interrupt)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("An error occurred when sending SIGINT to the ffmpeg process:")
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
case initUi:
|
case initUi:
|
||||||
m.FileCount = msg.fileCount
|
m.FileCount = msg.fileCount
|
||||||
m.Files = msg.files
|
m.Files = msg.files
|
||||||
@@ -166,6 +190,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig)
|
encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
case ffmpegProcessStart:
|
||||||
|
log.Printf("Running command: %s\n", msg.cmd.String())
|
||||||
|
m.Command = msg.cmd
|
||||||
return m, nil
|
return m, nil
|
||||||
case finishedEncodingVideo:
|
case finishedEncodingVideo:
|
||||||
if m.ParsedConfig.DeleteOldVideo {
|
if m.ParsedConfig.DeleteOldVideo {
|
||||||
@@ -209,14 +237,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseConfig(cfg []Config) ParsedConfig {
|
||||||
|
vEncoder := find(cfg, "Video Encoder")
|
||||||
|
aEncoder := find(cfg, "Audio Encoder")
|
||||||
|
preset := find(cfg, "Preset")
|
||||||
|
crf := find(cfg, "Constant Rate Factor (CRF)")
|
||||||
|
|
||||||
|
return ParsedConfig{
|
||||||
|
DeleteOldVideo: find(cfg, "Delete old video(s)?").FocusedOption != 0,
|
||||||
|
SkipEncodedVid: find(cfg, "What should we do about encoded videos?").FocusedOption == 0,
|
||||||
|
VideoEncoder: vEncoder.Opts[vEncoder.FocusedOption],
|
||||||
|
AudioEncoder: aEncoder.Opts[aEncoder.FocusedOption],
|
||||||
|
Preset: preset.Opts[preset.FocusedOption],
|
||||||
|
CRF: crf.Opts[crf.FocusedOption],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CfgScreenView(m model) string {
|
func CfgScreenView(m model) string {
|
||||||
view := ""
|
view := ""
|
||||||
|
|
||||||
for i, cfg := range Configs {
|
for i, cfg := range m.VisibleConfig {
|
||||||
opts := ""
|
opts := ""
|
||||||
|
|
||||||
for j, opt := range cfg.Opts {
|
for j, opt := range cfg.Opts {
|
||||||
if m.Config[i].FocusedOption == j {
|
if m.VisibleConfig[i].FocusedOption == j {
|
||||||
opts += FocusedOption.Render(opt)
|
opts += FocusedOption.Render(opt)
|
||||||
} else {
|
} else {
|
||||||
opts += BlurredOption.Render(opt)
|
opts += BlurredOption.Render(opt)
|
||||||
@@ -234,13 +278,13 @@ func CfgScreenView(m model) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.FocusIndex == len(Configs) {
|
if m.FocusIndex == len(m.VisibleConfig) {
|
||||||
view += FocusedStartButton
|
view += FocusedStartButton
|
||||||
} else {
|
} else {
|
||||||
view += BlurredStartButton
|
view += BlurredStartButton
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.FocusIndex == len(Configs)+1 {
|
if m.FocusIndex == len(m.VisibleConfig)+1 {
|
||||||
view += FocusedDryRunButton
|
view += FocusedDryRunButton
|
||||||
} else {
|
} else {
|
||||||
view += BlurredDryRunButton
|
view += BlurredDryRunButton
|
||||||
@@ -300,20 +344,27 @@ func formatEstimate(estimate int) string {
|
|||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
if m.Quitting {
|
if m.Quitting {
|
||||||
if m.DryRun {
|
if m.DryRun {
|
||||||
fullFilePath := m.Files[len(m.Files)-1]
|
sb := strings.Builder{}
|
||||||
parentDir := filepath.Dir(fullFilePath)
|
for _, file := range m.Files {
|
||||||
fileName := filepath.Base(fullFilePath)
|
parentDir := filepath.Dir(file)
|
||||||
|
fileName := filepath.Base(file)
|
||||||
extensionIndex := strings.LastIndex(fileName, ".")
|
extensionIndex := strings.LastIndex(fileName, ".")
|
||||||
newFileName := fileName[:extensionIndex]
|
newFileName := fileName[:extensionIndex]
|
||||||
extension := fileName[extensionIndex:]
|
extension := fileName[extensionIndex:]
|
||||||
outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension)
|
outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension)
|
||||||
|
|
||||||
cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(fullFilePath, outFileFullPath, m.ParsedConfig)...)
|
cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(file, outFileFullPath, m.ParsedConfig)...)
|
||||||
|
|
||||||
return fmt.Sprintf("%s %s\n", checkmark, cmd.String())
|
sb.WriteString(cmd.String())
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s\n", checkmark, sb.String())
|
||||||
} else {
|
} else {
|
||||||
return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount)
|
return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount)
|
||||||
}
|
}
|
||||||
|
} else if m.Cancelled {
|
||||||
|
return fmt.Sprintf("%s Encoding cancelled. Stopped ffmpeg process.\n Make sure to clean up the created file\n", x)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.ErrQuit {
|
if m.ErrQuit {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,6 +21,10 @@ type updateEstimate struct {
|
|||||||
estimate int
|
estimate int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ffmpegProcessStart struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
type initUi struct {
|
type initUi struct {
|
||||||
fileCount int
|
fileCount int
|
||||||
files []string
|
files []string
|
||||||
@@ -90,7 +95,7 @@ func (m *model) parseConfig(dryRun bool) tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
vEncoder := find(m.Config, "Video Encoder")
|
vEncoder := find(m.Config, "Video Encoder")
|
||||||
aEncoder := find(m.Config, "Audio Encoder")
|
aEncoder := find(m.Config, "Audio Encoder")
|
||||||
preset := find(m.Config, "Preset (libx264 & libx265 only)")
|
preset := find(m.Config, "Preset")
|
||||||
crf := find(m.Config, "Constant Rate Factor (CRF)")
|
crf := find(m.Config, "Constant Rate Factor (CRF)")
|
||||||
|
|
||||||
return parsedCfgMsg{
|
return parsedCfgMsg{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ var Configs = []Config{
|
|||||||
{Name: "What should we do about encoded videos?", Opts: []string{"Skip", "Delete and encode again"}},
|
{Name: "What should we do about encoded videos?", Opts: []string{"Skip", "Delete and encode again"}},
|
||||||
{Name: "Video Encoder", Opts: []string{"copy", "libx264", "libx265", "libvpx-vp9", "librav1e", "libsvtav1"}},
|
{Name: "Video Encoder", Opts: []string{"copy", "libx264", "libx265", "libvpx-vp9", "librav1e", "libsvtav1"}},
|
||||||
{Name: "Audio Encoder", Opts: []string{"None", "copy", "aac", "libopus"}, FocusedOption: 1},
|
{Name: "Audio Encoder", Opts: []string{"None", "copy", "aac", "libopus"}, FocusedOption: 1},
|
||||||
{Name: "Preset (libx264 & libx265 only)", Opts: []string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, FocusedOption: 4},
|
{Name: "Preset", Opts: []string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, FocusedOption: 4},
|
||||||
{Name: "Constant Rate Factor (CRF)", Opts: []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, FocusedOption: 4},
|
{Name: "Constant Rate Factor (CRF)", Opts: []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, FocusedOption: 4},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ type ParsedConfig struct {
|
|||||||
CRF string
|
CRF string
|
||||||
}
|
}
|
||||||
|
|
||||||
func find(cfgs map[int]Config, name string) Config {
|
func find(cfgs []Config, name string) Config {
|
||||||
for _, cfg := range cfgs {
|
for _, cfg := range cfgs {
|
||||||
if cfg.Name == name {
|
if cfg.Name == name {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -36,3 +36,33 @@ func find(cfgs map[int]Config, name string) Config {
|
|||||||
err := fmt.Sprintf("Couldn't find requested cfg: %s", name)
|
err := fmt.Sprintf("Couldn't find requested cfg: %s", name)
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filter(cfgs []Config, names ...string) []Config {
|
||||||
|
filtered := make([]Config, 0)
|
||||||
|
|
||||||
|
cfgloop:
|
||||||
|
for _, cfg := range cfgs {
|
||||||
|
for _, name := range names {
|
||||||
|
if cfg.Name == name {
|
||||||
|
continue cfgloop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVisibleConfigs(cfgs []Config) []Config {
|
||||||
|
parsed := parseConfig(cfgs)
|
||||||
|
|
||||||
|
switch parsed.VideoEncoder {
|
||||||
|
case "copy", "librav1e":
|
||||||
|
return filter(cfgs, "Preset", "Constant Rate Factor (CRF)")
|
||||||
|
case "libvpx-vp9", "libsvtav1":
|
||||||
|
return filter(cfgs, "Preset")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfgs
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFilterConfigs(t *testing.T) {
|
||||||
|
cfgs := filter(Configs, "Preset", "Constant Rate Factor (CRF)")
|
||||||
|
|
||||||
|
for _, cfg := range cfgs {
|
||||||
|
if cfg.Name == "Preset" {
|
||||||
|
t.Fatalf("Failed to filter out config \"Preset\"")
|
||||||
|
} else if cfg.Name == "Constant Rate Factor (CRF)" {
|
||||||
|
t.Fatalf("Failed to filter out config \"Constant Rate Factor (CRF)\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVisibleConfigs(t *testing.T) {
|
||||||
|
cfgs := getVisibleConfigs(Configs)
|
||||||
|
|
||||||
|
if len(cfgs) == len(Configs) {
|
||||||
|
t.Fatalf("Failed to get the correct visible configs. Expected VisibleConfigs len: %v. Got %v", 4, len(cfgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC
|
|||||||
|
|
||||||
if _, err := os.Stat(newFileFullPath); err == nil {
|
if _, err := os.Stat(newFileFullPath); err == nil {
|
||||||
if cfg.SkipEncodedVid {
|
if cfg.SkipEncodedVid {
|
||||||
log.Printf("%s already exists with the exact same encodings (crf and preset might be different though), skipping.", newFileFullPath)
|
log.Printf("Skipping \"%s\" because it already exists with the exact same encodings (crf and preset might be different though)", newFileFullPath)
|
||||||
teaP.Send(finishedEncodingVideo{})
|
teaP.Send(finishedEncodingVideo{})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@@ -130,8 +130,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC
|
|||||||
|
|
||||||
cmdArgs := buildFFmpegCmdArgs(fullFilePath, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(fullFilePath, teaP))
|
cmdArgs := buildFFmpegCmdArgs(fullFilePath, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(fullFilePath, teaP))
|
||||||
cmd := exec.Command("ffmpeg", cmdArgs...)
|
cmd := exec.Command("ffmpeg", cmdArgs...)
|
||||||
// TODO: send the cmd obj as a tea msg so we can send interrupt signals to the process when ctrl+c is pressed
|
teaP.Send(ffmpegProcessStart{cmd})
|
||||||
//teaP.Send()
|
|
||||||
err = cmd.Run()
|
err = cmd.Run()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user