From bb7c3921ab5998aed4e444b8ad84c4dff7bcd5d4 Mon Sep 17 00:00:00 2001 From: vpoluyaktov Date: Fri, 10 Nov 2023 16:05:40 -0800 Subject: [PATCH 1/4] Bootstrap controller implemented --- README.md | 4 +- go.mod | 1 + go.sum | 2 + .../controller/audiobookShelfController.go | 18 ++--- internal/controller/bootController.go | 75 +++++++++++++++++++ internal/controller/buildController.go | 15 ++-- internal/controller/chaptersController.go | 15 ++-- internal/controller/cleanupController.go | 13 ++-- internal/controller/conductor.go | 1 + internal/controller/configController.go | 8 +- internal/controller/copyController.go | 13 ++-- internal/controller/downloadController.go | 15 ++-- internal/controller/encodingController.go | 13 ++-- internal/controller/searchController.go | 16 ++-- internal/dto/audiobook.go | 37 ++++----- internal/dto/bootstrap.go | 26 +++++++ internal/dto/search.go | 8 ++ internal/mq/recepients.go | 1 + internal/ui/buildPage.go | 6 +- internal/ui/configPage.go | 15 ++-- internal/ui/dialog.go | 2 + internal/ui/downloadPage.go | 4 +- internal/ui/searchPage.go | 50 +++++++++++-- internal/utils/github.go | 49 ++++++++++++ internal/utils/misc.go | 7 +- 25 files changed, 310 insertions(+), 104 deletions(-) create mode 100644 internal/controller/bootController.go create mode 100644 internal/dto/bootstrap.go create mode 100644 internal/utils/github.go diff --git a/README.md b/README.md index 8dedaa0..6f0fc18 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,5 @@ Since the copyrights for the majority of old-time radio shows have expired and m ## TODO: -- Implement Search/Replace for Description on Chapters page -- Finish Default Settings screen -- Create an audiobook Settings screen + diff --git a/go.mod b/go.mod index 3303f6b..a1817d1 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( ) require ( + github.com/hashicorp/go-version v1.6.0 github.com/rivo/tview v0.0.0-20230406072732-e22ce9588bb4 github.com/stretchr/testify v1.8.3 golang.org/x/net v0.17.0 // indirect diff --git a/go.sum b/go.sum index e781efb..b9f8304 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCyS github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/internal/controller/audiobookShelfController.go b/internal/controller/audiobookShelfController.go index 5e52b46..c7a89bf 100644 --- a/internal/controller/audiobookShelfController.go +++ b/internal/controller/audiobookShelfController.go @@ -2,7 +2,6 @@ package controller import ( "github.com/vpoluyaktov/abb_ia/internal/audiobookshelf" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/logger" "github.com/vpoluyaktov/abb_ia/internal/mq" @@ -13,10 +12,10 @@ type AudiobookshelfController struct { } func NewAudiobookshelfController(dispatcher *mq.Dispatcher) *AudiobookshelfController { - dc := &AudiobookshelfController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.AudiobookshelfController, dc.dispatchMessage) - return dc + c := &AudiobookshelfController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.AudiobookshelfController, c.dispatchMessage) + return c } func (c *AudiobookshelfController) checkMQ() { @@ -37,10 +36,11 @@ func (c *AudiobookshelfController) dispatchMessage(m *mq.Message) { func (c *AudiobookshelfController) audiobookshelfScan(cmd *dto.AudiobookshelfScanCommand) { logger.Info(mq.AudiobookshelfController + " received " + cmd.String()) - url := config.Instance().GetAudiobookshelfUrl() - username := config.Instance().GetAudiobookshelfUser() - password := config.Instance().GetAudiobookshelfPassword() - libraryName := config.Instance().GetAudiobookshelfLibrary() + ab := cmd.Audiobook + url := ab.Config.GetAudiobookshelfUrl() + username := ab.Config.GetAudiobookshelfUser() + password := ab.Config.GetAudiobookshelfPassword() + libraryName := ab.Config.GetAudiobookshelfLibrary() if url != "" && username != "" && password != "" && libraryName != "" { loginResp, err := audiobookshelf.Login(url+"/login", username, password) diff --git a/internal/controller/bootController.go b/internal/controller/bootController.go new file mode 100644 index 0000000..75c7327 --- /dev/null +++ b/internal/controller/bootController.go @@ -0,0 +1,75 @@ +package controller + +import ( + "time" + + "github.com/vpoluyaktov/abb_ia/internal/config" + "github.com/vpoluyaktov/abb_ia/internal/dto" + "github.com/vpoluyaktov/abb_ia/internal/logger" + "github.com/vpoluyaktov/abb_ia/internal/mq" + "github.com/vpoluyaktov/abb_ia/internal/utils" +) + +type BootController struct { + mq *mq.Dispatcher +} + +func NewBootController(dispatcher *mq.Dispatcher) *BootController { + c := &BootController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.BootController, c.dispatchMessage) + go c.bootStrap(&dto.BootstrapCommand{}) + return c +} + +func (c *BootController) checkMQ() { + m := c.mq.GetMessage(mq.BootController) + if m != nil { + c.dispatchMessage(m) + } +} + +func (c *BootController) dispatchMessage(m *mq.Message) { + switch dto := m.Dto.(type) { + case *dto.BootstrapCommand: + go c.bootStrap(dto) + default: + m.UnsupportedTypeError(mq.BootController) + } +} + +func (c *BootController) bootStrap(cmd *dto.BootstrapCommand) { + // wait for all components to initialize + time.Sleep(3 * time.Second) + if c.checkFFmpeg() { + c.checkNewVersion() + } +} + +func (c *BootController) checkFFmpeg() bool { + if !(utils.CommandExists("ffmpeg") && utils.CommandExists("ffprobe")) { + logger.Fatal("Bootstrap: ffmpeg or ffprobe command not found") + c.mq.SendMessage(mq.BootController, mq.SearchPage, &dto.FFMPEGNotFoundError{}, true) + return false + } + return true +} + +func (c *BootController) checkNewVersion() { + + latestVersion, err := utils.GetLatestVersion("vpoluyaktov", "abb_ia") + if err != nil { + logger.Error("Can't check new version: " + err.Error()) + return + } + + result, err := utils.CompareVersions(latestVersion, config.Instance().AppVersion()) + if err != nil { + logger.Error("Can not compare versions: " + err.Error()) + return + } + + if result > 0 { + c.mq.SendMessage(mq.BootController, mq.SearchPage, &dto.NewAppVersionFound{CurrentVersion: config.Instance().AppVersion(), NewVersion: latestVersion}, true) + } +} diff --git a/internal/controller/buildController.go b/internal/controller/buildController.go index 47d6093..fb4d832 100644 --- a/internal/controller/buildController.go +++ b/internal/controller/buildController.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/ffmpeg" "github.com/vpoluyaktov/abb_ia/internal/utils" @@ -37,10 +36,10 @@ type fileBuild struct { } func NewBuildController(dispatcher *mq.Dispatcher) *BuildController { - dc := &BuildController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.BuildController, dc.dispatchMessage) - return dc + c := &BuildController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.BuildController, c.dispatchMessage) + return c } func (c *BuildController) checkMQ() { @@ -75,7 +74,7 @@ func (c *BuildController) startBuild(cmd *dto.BuildCommand) { // calculate output file names for i := range c.ab.Parts { part := &c.ab.Parts[i] - filePath := filepath.Join(config.Instance().GetOutputDir(), c.ab.Author+" - "+c.ab.Title) + filePath := filepath.Join(c.ab.Config.GetOutputDir(), c.ab.Author+" - "+c.ab.Title) if len(c.ab.Parts) > 1 { filePath = filePath + fmt.Sprintf(", Part %02d", i+1) } @@ -96,7 +95,7 @@ func (c *BuildController) startBuild(cmd *dto.BuildCommand) { // build audiobook parts c.stopFlag = false c.filesBuild = make([]fileBuild, len(c.ab.Parts)) - jd := utils.NewJobDispatcher(config.Instance().GetConcurrentEncoders()) + jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentEncoders()) for i := range c.ab.Parts { jd.AddJob(i, c.buildAudiobookPart, c.ab, i) } @@ -162,7 +161,7 @@ func (c *BuildController) createMetadata(ab *dto.Audiobook) { } func (c *BuildController) downloadCoverImage(ab *dto.Audiobook) error { - filePath := filepath.Join(config.Instance().GetOutputDir(), ab.Author+" - "+ab.Title) + filePath := filepath.Join(ab.Config.GetOutputDir(), ab.Author+" - "+ab.Title) if strings.HasSuffix(ab.CoverURL, ".jpg") { ab.CoverFile = filePath + ".jpg" } else if strings.HasSuffix(ab.CoverURL, ".png") { diff --git a/internal/controller/chaptersController.go b/internal/controller/chaptersController.go index 3677682..987f8ce 100644 --- a/internal/controller/chaptersController.go +++ b/internal/controller/chaptersController.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/ffmpeg" "github.com/vpoluyaktov/abb_ia/internal/logger" @@ -26,10 +25,10 @@ type ChaptersController struct { * This code is useful for creating a new ChaptersController instance and registering it with the message queue dispatcher. This allows the ChaptersController to receive messages from the message queue and dispatch them to the appropriate handler. **/ func NewChaptersController(dispatcher *mq.Dispatcher) *ChaptersController { - dc := &ChaptersController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.ChaptersController, dc.dispatchMessage) - return dc + c := &ChaptersController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.ChaptersController, c.dispatchMessage) + return c } func (c *ChaptersController) checkMQ() { @@ -76,7 +75,7 @@ func (c *ChaptersController) createChapters(cmd *dto.ChaptersCreate) { c.ab = cmd.Audiobook - if config.Instance().IsShortenTitle() { + if c.ab.Config.IsShortenTitle() { c.ab.Title = strings.ReplaceAll(c.ab.Title, " - Single Episodes", "") c.ab.Author = strings.ReplaceAll(c.ab.Author, "Old Time Radio Researchers Group", "OTRR") } @@ -109,7 +108,7 @@ func (c *ChaptersController) createChapters(cmd *dto.ChaptersCreate) { offset += mp3.Duration() chapterNo++ chapterFiles = []dto.Mp3File{} - if partSize >= int64(config.Instance().GetMaxFileSizeMb())*1024*1024 || i == len(c.ab.Mp3Files)-1 { + if partSize >= int64(c.ab.Config.GetMaxFileSizeMb())*1024*1024 || i == len(c.ab.Mp3Files)-1 { part := dto.Part{Number: partNo, Size: partSize, Duration: partDuration, Chapters: partChapters} c.ab.Parts = append(c.ab.Parts, part) partNo++ @@ -134,7 +133,7 @@ func (c *ChaptersController) searchReplaceDescription(cmd *dto.SearchReplaceDesc if err != nil { return } - + description := re.ReplaceAllString(ab.Description, replaceStr) ab.Description = description c.mq.SendMessage(mq.ChaptersController, mq.ChaptersPage, &dto.RefreshDescriptionCommand{Audiobook: cmd.Audiobook}, true) diff --git a/internal/controller/cleanupController.go b/internal/controller/cleanupController.go index ff1f032..af9cdbd 100644 --- a/internal/controller/cleanupController.go +++ b/internal/controller/cleanupController.go @@ -3,7 +3,6 @@ package controller import ( "os" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/logger" "github.com/vpoluyaktov/abb_ia/internal/mq" @@ -15,10 +14,10 @@ type CleanupController struct { } func NewCleanupController(dispatcher *mq.Dispatcher) *CleanupController { - dc := &CleanupController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.CleanupController, dc.dispatchMessage) - return dc + c := &CleanupController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.CleanupController, c.dispatchMessage) + return c } func (c *CleanupController) checkMQ() { @@ -41,14 +40,14 @@ func (c *CleanupController) cleanUp(cmd *dto.CleanupCommand) { logger.Info(mq.CleanupController + " received " + cmd.String()) c.ab = cmd.Audiobook - if !(config.Instance().IsSaveMock() || config.Instance().IsUseMock()) { + if !(c.ab.Config.IsSaveMock() || c.ab.Config.IsUseMock()) { os.RemoveAll(c.ab.OutputDir) } for _, part := range c.ab.Parts { os.Remove(part.AACFile) os.Remove(part.FListFile) os.Remove(part.MetadataFile) - if config.Instance().IsCopyToAudiobookshelf() { + if c.ab.Config.IsCopyToAudiobookshelf() { os.Remove(part.M4BFile) } } diff --git a/internal/controller/conductor.go b/internal/controller/conductor.go index 165ccd4..f064e00 100644 --- a/internal/controller/conductor.go +++ b/internal/controller/conductor.go @@ -27,6 +27,7 @@ func NewConductor(dispatcher *mq.Dispatcher) *Conductor { c.controllers = append(c.controllers, NewCopyController(c.dispatcher)) c.controllers = append(c.controllers, NewAudiobookshelfController(c.dispatcher)) c.controllers = append(c.controllers, NewCleanupController(c.dispatcher)) + c.controllers = append(c.controllers, NewBootController(c.dispatcher)) return c } diff --git a/internal/controller/configController.go b/internal/controller/configController.go index 4c012e1..01a5b51 100644 --- a/internal/controller/configController.go +++ b/internal/controller/configController.go @@ -12,10 +12,10 @@ type ConfigController struct { } func NewConfigController(dispatcher *mq.Dispatcher) *ConfigController { - sc := &ConfigController{} - sc.mq = dispatcher - sc.mq.RegisterListener(mq.ConfigController, sc.dispatchMessage) - return sc + c := &ConfigController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.ConfigController, c.dispatchMessage) + return c } func (c *ConfigController) checkMQ() { diff --git a/internal/controller/copyController.go b/internal/controller/copyController.go index fe8f7b9..849aa06 100644 --- a/internal/controller/copyController.go +++ b/internal/controller/copyController.go @@ -8,7 +8,6 @@ import ( "path/filepath" "time" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/utils" @@ -60,10 +59,10 @@ func (pr *ProgressReader) Read(p []byte) (int, error) { } func NewCopyController(dispatcher *mq.Dispatcher) *CopyController { - dc := &CopyController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.CopyController, dc.dispatchMessage) - return dc + c := &CopyController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.CopyController, c.dispatchMessage) + return c } func (c *CopyController) checkMQ() { @@ -109,7 +108,7 @@ func (c *CopyController) startCopy(cmd *dto.CopyCommand) { c.stopFlag = false c.filesCopy = make([]fileCopy, len(c.ab.Parts)) - jd := utils.NewJobDispatcher(config.Instance().GetConcurrentDownloaders()) + jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentDownloaders()) for i := range c.ab.Parts { jd.AddJob(i, c.copyAudiobookPart, c.ab, i) } @@ -140,7 +139,7 @@ func (c *CopyController) copyAudiobookPart(ab *dto.Audiobook, partId int) { defer file.Close() // Calculate Audiobookshelf directory structure (see: https://www.audiobookshelf.org/docs#book-directory-structure) - destPath := filepath.Join(config.Instance().GetAudiobookshelfDir(), ab.Author) + destPath := filepath.Join(ab.Config.GetAudiobookshelfDir(), ab.Author) if ab.Series != "" { destPath = filepath.Join(destPath, ab.Author+" - "+ab.Series) } diff --git a/internal/controller/downloadController.go b/internal/controller/downloadController.go index 0a829ad..66c6bcf 100644 --- a/internal/controller/downloadController.go +++ b/internal/controller/downloadController.go @@ -5,7 +5,6 @@ import ( "path/filepath" "time" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/ia_client" "github.com/vpoluyaktov/abb_ia/internal/logger" @@ -29,10 +28,10 @@ type fileDownload struct { } func NewDownloadController(dispatcher *mq.Dispatcher) *DownloadController { - dc := &DownloadController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.DownloadController, dc.dispatchMessage) - return dc + c := &DownloadController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.DownloadController, c.dispatchMessage) + return c } func (c *DownloadController) checkMQ() { @@ -70,7 +69,7 @@ func (c *DownloadController) startDownload(cmd *dto.DownloadCommand) { c.ab.Title = item.Title c.ab.Description = item.Description c.ab.CoverURL = item.CoverUrl - c.ab.OutputDir = utils.SanitizeFilePath(filepath.Join(config.Instance().GetOutputDir(), item.ID)) + c.ab.OutputDir = utils.SanitizeFilePath(filepath.Join(c.ab.Config.GetOutputDir(), item.ID)) c.ab.TotalSize = item.TotalSize c.ab.TotalDuration = item.TotalLength @@ -78,10 +77,10 @@ func (c *DownloadController) startDownload(cmd *dto.DownloadCommand) { c.mq.SendMessage(mq.DownloadController, mq.DownloadPage, &dto.DisplayBookInfoCommand{Audiobook: c.ab}, true) // download files - ia := ia_client.New(config.Instance().GetSearchRowsMax(), config.Instance().IsUseMock(), config.Instance().IsSaveMock()) + ia := ia_client.New(c.ab.Config.GetSearchRowsMax(), c.ab.Config.IsUseMock(), c.ab.Config.IsSaveMock()) c.stopFlag = false c.files = make([]fileDownload, len(item.AudioFiles)) - jd := utils.NewJobDispatcher(config.Instance().GetConcurrentDownloaders()) + jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentDownloaders()) for i, iaFile := range item.AudioFiles { localFileName := utils.SanitizeFilePath(filepath.Join(item.Dir, iaFile.Name)) c.ab.Mp3Files = append(c.ab.Mp3Files, dto.Mp3File{Number: i, FileName: localFileName, Size: iaFile.Size, Duration: iaFile.Length}) diff --git a/internal/controller/encodingController.go b/internal/controller/encodingController.go index 9de033b..9ae0d01 100644 --- a/internal/controller/encodingController.go +++ b/internal/controller/encodingController.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/ffmpeg" "github.com/vpoluyaktov/abb_ia/internal/logger" @@ -32,10 +31,10 @@ type fileEncode struct { } func NewEncodingController(dispatcher *mq.Dispatcher) *EncodingController { - dc := &EncodingController{} - dc.mq = dispatcher - dc.mq.RegisterListener(mq.EncodingController, dc.dispatchMessage) - return dc + c := &EncodingController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.EncodingController, c.dispatchMessage) + return c } func (c *EncodingController) checkMQ() { @@ -74,7 +73,7 @@ func (c *EncodingController) startEncoding(cmd *dto.EncodeCommand) { // re-encode files c.stopFlag = false c.files = make([]fileEncode, len(c.ab.Mp3Files)) - jd := utils.NewJobDispatcher(config.Instance().GetConcurrentEncoders()) + jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentEncoders()) for i, f := range c.ab.Mp3Files { jd.AddJob(i, c.encodeFile, i, c.ab.OutputDir, f.FileName) } @@ -106,7 +105,7 @@ func (c *EncodingController) encodeFile(fileId int, outputDir string, fileName s // launch ffmpeg process _, err := ffmpeg.NewFFmpeg(). Input(filePath, "-f mp3"). - Output(tmpFile, fmt.Sprintf("-f mp3 -ab %dk -ar %d -vn", config.Instance().GetBitRate(), config.Instance().GetSampleRate())). + Output(tmpFile, fmt.Sprintf("-f mp3 -ab %dk -ar %d -vn", c.ab.Config.GetBitRate(), c.ab.Config.GetSampleRate())). Overwrite(true). Params("-hide_banner -nostdin -nostats -loglevel fatal"). SendProgressTo("http://127.0.0.1:" + strconv.Itoa(port)). diff --git a/internal/controller/searchController.go b/internal/controller/searchController.go index 9dff695..61b09b1 100644 --- a/internal/controller/searchController.go +++ b/internal/controller/searchController.go @@ -20,10 +20,10 @@ type SearchController struct { } func NewSearchController(dispatcher *mq.Dispatcher) *SearchController { - sc := &SearchController{} - sc.mq = dispatcher - sc.mq.RegisterListener(mq.SearchController, sc.dispatchMessage) - return sc + c := &SearchController{} + c.mq = dispatcher + c.mq.RegisterListener(mq.SearchController, c.dispatchMessage) + return c } func (c *SearchController) checkMQ() { @@ -143,7 +143,11 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { } } logger.Debug(mq.SearchController + " fetched first " + strconv.Itoa(itemsFetched) + " items from " + strconv.Itoa(itemsTotal) + " total") - c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.SetBusyIndicator{Busy: false}, false) - c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.UpdateStatus{Message: ""}, false) + } + c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.SetBusyIndicator{Busy: false}, false) + c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.UpdateStatus{Message: ""}, false) + + if itemsFetched == 0 { + c.mq.SendMessage(mq.SearchController, mq.SearchPage, &dto.NothingFoundError{SearchCondition: cmd.SearchCondition}, false) } } diff --git a/internal/dto/audiobook.go b/internal/dto/audiobook.go index 99e1ddd..2adae42 100644 --- a/internal/dto/audiobook.go +++ b/internal/dto/audiobook.go @@ -3,6 +3,8 @@ package dto import ( "encoding/json" "fmt" + + "github.com/vpoluyaktov/abb_ia/internal/config" ) type Audiobook struct { @@ -23,6 +25,7 @@ type Audiobook struct { TotalSize int64 Parts []Part IAItem *IAItem + Config *config.Config } type Part struct { @@ -83,20 +86,20 @@ func (ab *Audiobook) SetChapter(chapterNumber int, ch Chapter) error { } func (ab *Audiobook) GetCopy() (*Audiobook, error) { - // Convert the source struct to JSON - jsonBytes, err := json.Marshal(ab) - if err != nil { - return nil, err - } - - // Create a new destination struct - destination := &Audiobook{} - - // Convert the JSON back to the destination struct - err = json.Unmarshal(jsonBytes, destination) - if err != nil { - return nil, err - } - - return destination, nil -} \ No newline at end of file + // Convert the source struct to JSON + jsonBytes, err := json.Marshal(ab) + if err != nil { + return nil, err + } + + // Create a new destination struct + destination := &Audiobook{} + + // Convert the JSON back to the destination struct + err = json.Unmarshal(jsonBytes, destination) + if err != nil { + return nil, err + } + + return destination, nil +} diff --git a/internal/dto/bootstrap.go b/internal/dto/bootstrap.go new file mode 100644 index 0000000..b1a0d21 --- /dev/null +++ b/internal/dto/bootstrap.go @@ -0,0 +1,26 @@ +package dto + +import "fmt" + +type BootstrapCommand struct { +} + +func (c *BootstrapCommand) String() string { + return fmt.Sprintf("BootstrapCommand: bootstrap") +} + +type FFMPEGNotFoundError struct { +} + +func (c *FFMPEGNotFoundError) String() string { + return fmt.Sprintf("FFMPEGNotFoundError: FFMPEG not found") +} + +type NewAppVersionFound struct { + CurrentVersion string + NewVersion string +} + +func (c *NewAppVersionFound) String() string { + return fmt.Sprintf("NewAppVersionFound: %s", c.NewVersion) +} \ No newline at end of file diff --git a/internal/dto/search.go b/internal/dto/search.go index 37dd198..83ea30a 100644 --- a/internal/dto/search.go +++ b/internal/dto/search.go @@ -16,6 +16,14 @@ func (c *SearchCommand) String() string { return fmt.Sprintf("SearchCommand: %s", c.SearchCondition) } +type NothingFoundError struct { + SearchCondition string +} + +func (c *NothingFoundError) String() string { + return fmt.Sprintf("NothingFoundError: %s", c.SearchCondition) +} + type SearchProgress struct { ItemsTotal int ItemsFetched int diff --git a/internal/mq/recepients.go b/internal/mq/recepients.go index 090e35e..b5659a9 100644 --- a/internal/mq/recepients.go +++ b/internal/mq/recepients.go @@ -13,6 +13,7 @@ const ( EncodingPage = "EncodingPage" ChaptersPage = "ChaptersPage" BuildPage = "BuildPage" + BootController = "BootController" SearchController = "SearchController" ConfigController = "ConfigController" DownloadController = "DownloadController" diff --git a/internal/ui/buildPage.go b/internal/ui/buildPage.go index 49602a8..7481e26 100644 --- a/internal/ui/buildPage.go +++ b/internal/ui/buildPage.go @@ -8,7 +8,6 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/mq" "github.com/vpoluyaktov/abb_ia/internal/utils" @@ -150,7 +149,7 @@ func (p *BuildPage) displayBookInfo(ab *dto.Audiobook) { } p.buildTable.ScrollToBeginning() - if config.Instance().IsCopyToAudiobookshelf() { + if ab.Config.IsCopyToAudiobookshelf() { p.copyTable.clear() p.copyTable.showHeader() for i, part := range ab.Parts { @@ -250,7 +249,8 @@ func (p *BuildPage) updateTotalCopyProgress(dp *dto.CopyProgress) { func (p *BuildPage) buildComplete(c *dto.BuildComplete) { // copy book to Audiobookshelf if needed - if config.Instance().IsCopyToAudiobookshelf() { + ab := c.Audiobook + if ab.Config.IsCopyToAudiobookshelf() { p.mq.SendMessage(mq.BuildPage, mq.CopyController, &dto.CopyCommand{Audiobook: c.Audiobook}, true) } else { p.mq.SendMessage(mq.BuildPage, mq.CleanupController, &dto.CleanupCommand{Audiobook: c.Audiobook}, true) diff --git a/internal/ui/configPage.go b/internal/ui/configPage.go index adb74ef..099f066 100644 --- a/internal/ui/configPage.go +++ b/internal/ui/configPage.go @@ -67,19 +67,20 @@ func newConfigPage(dispatcher *mq.Dispatcher) *ConfigPage { p.configSection.SetTitle(" Audiobook Builder Configuration: ") p.configSection.SetTitleAlign(tview.AlignLeft) + configFormLeft := newForm() configFormLeft.SetHorizontal(false) - p.outputDir = configFormLeft.AddInputField("Output (working) directory:", "", 40, nil, func(t string) { p.configCopy.SetOutputDir(t) }) - p.logFileNameField = configFormLeft.AddInputField("Log file name:", "", 40, nil, func(t string) { p.configCopy.SetLogfileName(t) }) - p.logLevelField = configFormLeft.AddDropdown("Log level:", utils.AddSpaces(logger.LogLeves()), 1, func(o string, i int) { p.configCopy.SetLogLevel(strings.TrimSpace(o)) }) - p.useMockField = configFormLeft.AddCheckbox("Use mock:", false, func(t bool) { p.configCopy.SetUseMock(t) }) - p.saveMockField = configFormLeft.AddCheckbox("Save mock:", false, func(t bool) { p.configCopy.SetSaveMock(t) }) + p.searchCondition = configFormLeft.AddInputField("Default Search condition:", "", 40, nil, func(t string) { p.configCopy.SetSearchCondition(t) }) + p.maxSearchRows = configFormLeft.AddInputField("Maximum rows in the search result:", "", 4, acceptInt, func(t string) { p.configCopy.SetSearchRowsMax(utils.ToInt(t)) }) p.configSection.AddItem(configFormLeft.f, 0, 0, 1, 1, 0, 0, true) configFormRight := newForm() configFormRight.SetHorizontal(false) - p.searchCondition = configFormRight.AddInputField("Default Search condition", "", 40, nil, func(t string) { p.configCopy.SetSearchCondition(t) }) - p.maxSearchRows = configFormRight.AddInputField("Maximum rows in the search result:", "", 4, acceptInt, func(t string) { p.configCopy.SetSearchRowsMax(utils.ToInt(t)) }) + p.outputDir = configFormRight.AddInputField("Output (working) directory:", "", 40, nil, func(t string) { p.configCopy.SetOutputDir(t) }) + p.logFileNameField = configFormRight.AddInputField("Log file name:", "", 40, nil, func(t string) { p.configCopy.SetLogfileName(t) }) + p.logLevelField = configFormRight.AddDropdown("Log level:", utils.AddSpaces(logger.LogLeves()), 1, func(o string, i int) { p.configCopy.SetLogLevel(strings.TrimSpace(o)) }) + p.useMockField = configFormRight.AddCheckbox("Use mock:", false, func(t bool) { p.configCopy.SetUseMock(t) }) + p.saveMockField = configFormRight.AddCheckbox("Save mock:", false, func(t bool) { p.configCopy.SetSaveMock(t) }) p.configSection.AddItem(configFormRight.f, 0, 1, 1, 1, 0, 0, true) buttonsForm := newForm() diff --git a/internal/ui/dialog.go b/internal/ui/dialog.go index 6d2aaec..948547d 100644 --- a/internal/ui/dialog.go +++ b/internal/ui/dialog.go @@ -96,6 +96,7 @@ func newMessageDialog(dispatcher *mq.Dispatcher, title string, message string, f tv := tview.NewTextView() tv.SetWrap(true) tv.SetWordWrap(true) + tv.SetDynamicColors(true) tv.SetText(message) tv.SetTextAlign(tview.AlignCenter) f.AddFormItem(tv) @@ -115,6 +116,7 @@ func newYesNoDialog(dispatcher *mq.Dispatcher, title string, message string, foc tv := tview.NewTextView() tv.SetWrap(true) tv.SetWordWrap(true) + tv.SetDynamicColors(true) tv.SetText(message) tv.SetTextAlign(tview.AlignCenter) f.AddFormItem(tv) diff --git a/internal/ui/downloadPage.go b/internal/ui/downloadPage.go index 6ac5755..2213ec1 100644 --- a/internal/ui/downloadPage.go +++ b/internal/ui/downloadPage.go @@ -7,7 +7,6 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" - "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/mq" "github.com/vpoluyaktov/abb_ia/internal/utils" @@ -179,7 +178,8 @@ func (p *DownloadPage) updateTotalProgress(dp *dto.DownloadProgress) { } func (p *DownloadPage) downloadComplete(c *dto.DownloadComplete) { - if config.Instance().IsReEncodeFiles() { + ab := c.Audiobook + if ab.Config.IsReEncodeFiles() { p.mq.SendMessage(mq.DownloadPage, mq.EncodingController, &dto.EncodeCommand{Audiobook: c.Audiobook}, true) p.mq.SendMessage(mq.DownloadPage, mq.Frame, &dto.SwitchToPageCommand{Name: "EncodingPage"}, false) } else { diff --git a/internal/ui/searchPage.go b/internal/ui/searchPage.go index 4d09e99..be0db2a 100644 --- a/internal/ui/searchPage.go +++ b/internal/ui/searchPage.go @@ -135,6 +135,12 @@ func (p *SearchPage) dispatchMessage(m *mq.Message) { go p.updateResult(dto) case *dto.SearchProgress: p.updateTitle(dto) + case *dto.NothingFoundError: + p.showNothingFoundError(dto) + case *dto.NewAppVersionFound: + p.showNewVersionMessage(dto) + case *dto.FFMPEGNotFoundError: + p.showFFMPEGNotFoundError(dto) default: m.UnsupportedTypeError(mq.SearchPage) } @@ -200,16 +206,23 @@ func (p *SearchPage) createBook() { newMessageDialog(p.mq, "Error", "Please perform a search first", p.searchSection) } else { item := p.searchResult[row-1] - d := newDialogWindow(p.mq, 12, 80, p.resultSection) + // create new audiobook object + ab := &dto.Audiobook{} + ab.IAItem = item + c := config.Instance().GetCopy() + ab.Config = &c + + d := newDialogWindow(p.mq, 17, 55, p.resultSection) f := newForm() f.SetTitle("Create Audiobook") - // author := f.AddInputField("Book Author", item.Creator, 60, nil, nil) - // title := f.AddInputField("Book Title", item.Title, 60, nil, nil) + f.AddInputField("Concurrent Downloaders:", utils.ToString(ab.Config.GetConcurrentDownloaders()), 4, acceptInt, func(t string) { ab.Config.SetConcurrentDownloaders(utils.ToInt(t)) }) + f.AddInputField("Concurrent Encoders:", utils.ToString(ab.Config.GetConcurrentEncoders()), 4, acceptInt, func(t string) { ab.Config.SetConcurrentEncoders(utils.ToInt(t)) }) + f.AddCheckbox("Re-encode .mp3 files to the same Bit Rate?", ab.Config.IsReEncodeFiles(), func(t bool) { ab.Config.SetReEncodeFiles(t) }) + f.AddInputField("Bit Rate (Kbps):", utils.ToString(ab.Config.GetBitRate()), 4, acceptInt, func(t string) { ab.Config.SetBitRate(utils.ToInt(t)) }) + f.AddInputField("Sample Rate (Hz):", utils.ToString(ab.Config.GetSampleRate()), 6, acceptInt, func(t string) { ab.Config.SetSampleRate(utils.ToInt(t)) }) + f.AddInputField("Audiobook part max file size (Mb):", utils.ToString(ab.Config.GetMaxFileSizeMb()), 6, acceptInt, func(t string) { ab.Config.SetMaxFileSizeMb(utils.ToInt(t)) }) + f.AddButton("Create Audiobook", func() { - ab := &dto.Audiobook{} - ab.IAItem = item - // ab.Title = title.GetText() - // ab.Author = author.GetText() p.startDownload(ab) d.Close() }) @@ -230,3 +243,26 @@ func (p *SearchPage) updateConfig() { p.mq.SendMessage(mq.SearchPage, mq.ConfigPage, &dto.DisplayConfigCommand{Config: config.Instance().GetCopy()}, true) p.mq.SendMessage(mq.SearchPage, mq.Frame, &dto.SwitchToPageCommand{Name: "ConfigPage"}, false) } + +func (p *SearchPage) showNothingFoundError(dto *dto.NothingFoundError) { + newMessageDialog(p.mq, "Error", + "No results were found for your search term: [darkblue]'"+dto.SearchCondition+"'[black].\n"+ + "Please revise your search criteria.", + p.searchSection) +} + +func (p *SearchPage) showFFMPEGNotFoundError(dto *dto.FFMPEGNotFoundError) { + newMessageDialog(p.mq, "Error", + "This application requires the utilities [darkblue]ffmpeg[black] and [darkblue]ffprobe[black].\n"+ + "Please install both [darkblue]ffmpeg[black] and [darkblue]ffprobe[black] by following the instructions provided on FFMPEG website\n"+ + "[darkblue]https://ffmpeg.org/download.html", + p.searchSection) +} + +func (p *SearchPage) showNewVersionMessage(dto *dto.NewAppVersionFound) { + newMessageDialog(p.mq, "Notification", + "New version of the application has been released: [darkblue]"+dto.NewVersion+"[black]\n"+ + "Your current version is [darkblue]"+dto.CurrentVersion+"[black]\n"+ + "You can download the new version of the application from:\n[darkblue]https://github.com/vpoluyaktov/abb_ia/releases", + p.searchSection) +} diff --git a/internal/utils/github.go b/internal/utils/github.go new file mode 100644 index 0000000..9fc9a95 --- /dev/null +++ b/internal/utils/github.go @@ -0,0 +1,49 @@ +package utils + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/hashicorp/go-version" +) + +type Release struct { + TagName string `json:"tag_name"` +} + +func GetLatestVersion(owner string, repo string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch latest release: %s", resp.Status) + } + + var release Release + err = json.NewDecoder(resp.Body).Decode(&release) + if err != nil { + return "", err + } + + return release.TagName, nil +} + +func CompareVersions(version1 string, version2 string) (int, error) { + v1, err := version.NewVersion(version1) + if err != nil { + return 0, fmt.Errorf("invalid version format: %s", version1) + } + + v2, err := version.NewVersion(version2) + if err != nil { + return 0, fmt.Errorf("invalid version format: %s", version2) + } + + return v1.Compare(v2), nil +} diff --git a/internal/utils/misc.go b/internal/utils/misc.go index 789e7b4..d190b85 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -1,6 +1,6 @@ package utils -import () +import "os/exec" // Check if a map contains a given key func HasKey(m map[string]interface{}, key string) bool { @@ -35,3 +35,8 @@ func AddSpaces(list []string) []string { } return output } + +func CommandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} From 9edad0322d321f575d98e3bbbe10a261782b8555 Mon Sep 17 00:00:00 2001 From: vpoluyaktov Date: Mon, 13 Nov 2023 13:33:40 -0800 Subject: [PATCH 2/4] Image URL calculation fixed --- internal/controller/searchController.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/controller/searchController.go b/internal/controller/searchController.go index 61b09b1..a866c36 100644 --- a/internal/controller/searchController.go +++ b/internal/controller/searchController.go @@ -1,7 +1,7 @@ package controller import ( - "path/filepath" + "net/url" "sort" "strconv" "strings" @@ -130,7 +130,7 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { biggestImage = item.ImageFiles[i] } } - item.CoverUrl = "https://" + filepath.Join(item.Server, item.Dir, biggestImage.Name) + item.CoverUrl = (&url.URL{Scheme: "https", Host: item.Server, Path: item.Dir + "/" + biggestImage.Name}).String() } else { item.CoverUrl = "No cover available!" } From ccaf65ec54fd49af864a0b82a4e5573b83f2e4f8 Mon Sep 17 00:00:00 2001 From: vpoluyaktov Date: Mon, 13 Nov 2023 14:15:22 -0800 Subject: [PATCH 3/4] GitHub version checker refactored --- internal/config/config.go | 10 ++++++++++ internal/controller/bootController.go | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 82a7bcd..dd5e705 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,8 @@ var ( var ( configFile = "config.yaml" appVersion, buildDate string + repoOwner string = "vpoluyaktov" + repoName string = "abb_ia" ) // Fields of this stuct should to be private but I have to make them public because yaml.Marshal/Unmarshal can't work with private fields @@ -314,6 +316,14 @@ func (c *Config) AppVersion() string { return appVersion } +func (c *Config) GetRepoOwner() string { + return repoOwner +} + +func (c *Config) GetRepoName() string { + return repoName +} + func (c *Config) GetBuildDate() string { // 2023-07-20T14:45:12Z fmt := "01/02/2006" diff --git a/internal/controller/bootController.go b/internal/controller/bootController.go index 75c7327..c6e8d50 100644 --- a/internal/controller/bootController.go +++ b/internal/controller/bootController.go @@ -57,7 +57,7 @@ func (c *BootController) checkFFmpeg() bool { func (c *BootController) checkNewVersion() { - latestVersion, err := utils.GetLatestVersion("vpoluyaktov", "abb_ia") + latestVersion, err := utils.GetLatestVersion(config.Instance().GetRepoOwner(), config.Instance().GetRepoName()) if err != nil { logger.Error("Can't check new version: " + err.Error()) return From b9915b62ce2445b31e5a7cefb761879be91bf2a1 Mon Sep 17 00:00:00 2001 From: vpoluyaktov Date: Tue, 14 Nov 2023 18:47:59 -0800 Subject: [PATCH 4/4] FFMPEG progress reader refactored --- internal/config/config.go | 10 ++ internal/controller/buildController.go | 143 ++++++++++---------- internal/controller/encodingController.go | 154 +++++++++++----------- internal/ffmpeg/utils.go | 43 ++++++ internal/ui/buildPage.go | 2 +- internal/ui/encodingPage.go | 2 +- 6 files changed, 203 insertions(+), 151 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index dd5e705..5980818 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type Config struct { ConcurrentDownloaders int `yaml:"ConcurrentDownloaders"` ConcurrentEncoders int `yaml:"ConcurrentEncoders"` ReEncodeFiles bool `yaml:"ReEncodeFiles"` + BasePortNumber int `yaml:"BasePortNumber"` BitRateKbs int `yaml:"BitRateKbs"` SampleRateHz int `yaml:"SampleRateHz"` MaxFileSizeMb int `yaml:"MaxFileSizeMb"` @@ -69,6 +70,7 @@ func Load() { config.ConcurrentDownloaders = 5 config.ConcurrentEncoders = 5 config.ReEncodeFiles = true + config.BasePortNumber = 31000 config.BitRateKbs = 96 config.SampleRateHz = 44100 config.MaxFileSizeMb = 250 @@ -205,6 +207,14 @@ func (c *Config) IsReEncodeFiles() bool { return c.ReEncodeFiles } +func (c *Config) SetBasePortNumber(port int) { + c.BasePortNumber = port +} + +func (c *Config) GetBasePortNumber() int { + return c.BasePortNumber +} + func (c *Config) SetBitRate(b int) { c.BitRateKbs = b } diff --git a/internal/controller/buildController.go b/internal/controller/buildController.go index fb4d832..0df8926 100644 --- a/internal/controller/buildController.go +++ b/internal/controller/buildController.go @@ -7,7 +7,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strconv" "strings" "time" @@ -25,14 +24,18 @@ type BuildController struct { ab *dto.Audiobook startTime time.Time stopFlag bool - - // progress tracking arrays - filesBuild []fileBuild + files []fileBuild } +// progress tracking arrays type fileBuild struct { - fileId int - progress int + fileName string + totalDuration float64 + bytesProcessed int64 + secondsProcessed float64 + encodingSpeed float64 + progress int + complete bool } func NewBuildController(dispatcher *mq.Dispatcher) *BuildController { @@ -70,6 +73,8 @@ func (c *BuildController) startBuild(cmd *dto.BuildCommand) { logger.Info(mq.BuildController + " received " + cmd.String()) c.ab = cmd.Audiobook + c.stopFlag = false + c.files = make([]fileBuild, len(c.ab.Parts)) // calculate output file names for i := range c.ab.Parts { @@ -80,6 +85,7 @@ func (c *BuildController) startBuild(cmd *dto.BuildCommand) { } part.AACFile = filePath + ".aac" part.M4BFile = filePath + ".m4b" + c.files[i].totalDuration = part.Duration } c.mq.SendMessage(mq.BuildController, mq.BuildPage, &dto.DisplayBookInfoCommand{Audiobook: c.ab}, true) @@ -93,16 +99,12 @@ func (c *BuildController) startBuild(cmd *dto.BuildCommand) { c.downloadCoverImage(c.ab) // build audiobook parts - c.stopFlag = false - c.filesBuild = make([]fileBuild, len(c.ab.Parts)) + jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentEncoders()) for i := range c.ab.Parts { jd.AddJob(i, c.buildAudiobookPart, c.ab, i) } - go c.updateTotalBuildProgress() - // if c.stopFlag { - // break - // } + go c.updateTotalProgress() jd.Start() c.stopFlag = true @@ -193,12 +195,16 @@ func (c *BuildController) downloadCoverImage(ab *dto.Audiobook) error { } func (c *BuildController) buildAudiobookPart(ab *dto.Audiobook, partId int) { + if c.stopFlag { + return + } + part := &ab.Parts[partId] // launch progress listener l, port := c.startProgressListener(partId) defer l.Close() - go c.updateFileBuildProgress(partId, part.M4BFile, part.Duration, l) + go c.updateFileProgress(partId, l) // concatenate mp3 files into single .aac file _, err := ffmpeg.NewFFmpeg(). @@ -239,95 +245,88 @@ func (c *BuildController) startProgressListener(fileId int) (net.Listener, int) return l, portNumber } -func (c *BuildController) updateFileBuildProgress(fileId int, fileName string, totalDuration float64, l net.Listener) { - - re := regexp.MustCompile(`out_time_ms=(\d+)`) +func (c *BuildController) updateFileProgress(fileId int, l net.Listener) { fd, err := l.Accept() if err != nil { - return // listener is closed + return // listener may be closed already } buf := make([]byte, 16) data := "" percent := 0 - for { + + for !c.stopFlag { _, err := fd.Read(buf) if err != nil { return // listener is closed } data += string(buf) - a := re.FindAllStringSubmatch(data, -1) - p := 0 - pstr := "" - if len(a) > 0 && len(a[len(a)-1]) > 0 { - c, _ := strconv.Atoi(a[len(a)-1][len(a[len(a)-1])-1]) - pstr = fmt.Sprintf("%.2f", float64(c)/totalDuration/1000000) - } - if strings.Contains(data, "progress=end") { - p = 100 + bytesProcessed, secondsProcessed, encodingSpeed, complete := ffmpeg.ParseFFMPEGProgress(data) + percent = int(secondsProcessed / c.files[fileId].totalDuration * 100) + // wrong calculation protection + if percent < 0 { + percent = 0 + } else if percent > 100 { + percent = 100 + } else if percent < c.files[fileId].progress { + percent = c.files[fileId].progress + } else if complete { + percent = 100 } - if pstr == "" { - p = 0 - } - pflt, err := strconv.ParseFloat(pstr, 64) - if err != nil { - p = 0 - } else { - p = int(pflt * 100) - } - - if p != percent { - percent = p - // wrong calculation protection - if percent < 0 { - percent = 0 - } else if percent > 100 { - percent = 100 - } - - // sent a message only if progress changed - c.mq.SendMessage(mq.BuildController, mq.BuildPage, &dto.BuildFileProgress{FileId: fileId, FileName: fileName, Percent: percent}, true) + // sent a message only if progress changed + if percent != c.files[fileId].progress { + c.files[fileId].bytesProcessed = bytesProcessed + c.files[fileId].secondsProcessed = secondsProcessed + c.files[fileId].encodingSpeed = encodingSpeed + c.files[fileId].progress = percent + c.files[fileId].complete = complete + c.mq.SendMessage(mq.BuildController, mq.BuildPage, &dto.BuildFileProgress{FileId: fileId, FileName: c.files[fileId].fileName, Percent: percent}, true) } - c.filesBuild[fileId].fileId = fileId - c.filesBuild[fileId].progress = percent } } -func (c *BuildController) updateTotalBuildProgress() { - var percent int = -1 - - for !c.stopFlag && percent <= 100 { - var totalPercent int = 0 - files := 0 - for _, f := range c.filesBuild { - totalPercent += f.progress - if f.progress == 100 { - files++ +func (c *BuildController) updateTotalProgress() { + var p int = -1 + + for !c.stopFlag && p <= 100 { + var totalDuration float64 = 0 + var secondsProcessed float64 = 0 + var totalSpeed float64 = 0 + filesProcessed := 0 + filesComplete := 0 + for _, f := range c.files { + totalDuration += f.totalDuration + secondsProcessed += f.secondsProcessed + totalSpeed += f.encodingSpeed + if f.complete { + filesComplete++ + } + if f.encodingSpeed > 0 { + filesProcessed++ } } - p := int(totalPercent / len(c.filesBuild)) + percent := int(secondsProcessed / totalDuration * 100) + // wrong calculation protection + if percent < 0 { + percent = 0 + } else if percent > 100 { + percent = 100 + } if percent != p { // sent a message only if progress changed - percent = p - - // wrong calculation protection - if percent < 0 { - percent = 0 - } else if percent > 100 { - percent = 100 - } + p = percent elapsed := time.Since(c.startTime).Seconds() - speed := int64(float64(percent) / elapsed) + speed := totalSpeed / float64(filesProcessed) eta := (100 / (float64(percent) / elapsed)) - elapsed if eta < 0 || eta > (60*60*24*365) { eta = 0 } elapsedH := utils.SecondsToTime(elapsed) - filesH := fmt.Sprintf("%d/%d", files, len(c.ab.Parts)) - speedH := utils.SpeedToHuman(speed) + filesH := fmt.Sprintf("%d/%d", filesComplete, len(c.ab.Parts)) + speedH := fmt.Sprintf("%.0fx", speed) etaH := utils.SecondsToTime(eta) c.mq.SendMessage(mq.BuildController, mq.BuildPage, &dto.BuildProgress{Elapsed: elapsedH, Percent: percent, Files: filesH, Speed: speedH, ETA: etaH}, true) diff --git a/internal/controller/encodingController.go b/internal/controller/encodingController.go index 9ae0d01..aa41484 100644 --- a/internal/controller/encodingController.go +++ b/internal/controller/encodingController.go @@ -5,11 +5,10 @@ import ( "net" "os" "path/filepath" - "regexp" "strconv" - "strings" "time" + "github.com/vpoluyaktov/abb_ia/internal/config" "github.com/vpoluyaktov/abb_ia/internal/dto" "github.com/vpoluyaktov/abb_ia/internal/ffmpeg" "github.com/vpoluyaktov/abb_ia/internal/logger" @@ -25,9 +24,17 @@ type EncodingController struct { stopFlag bool } +// progress tracking arrays type fileEncode struct { - fileId int - progress int + fileId int + fileName string + filePath string + totalDuration float64 + bytesProcessed int64 + secondsProcessed float64 + encodingSpeed float64 + progress int + complete bool } func NewEncodingController(dispatcher *mq.Dispatcher) *EncodingController { @@ -65,23 +72,25 @@ func (c *EncodingController) startEncoding(cmd *dto.EncodeCommand) { logger.Info(mq.EncodingController + " received " + cmd.String()) c.ab = cmd.Audiobook + c.stopFlag = false + c.files = make([]fileEncode, len(c.ab.Mp3Files)) c.mq.SendMessage(mq.EncodingController, mq.EncodingPage, &dto.DisplayBookInfoCommand{Audiobook: c.ab}, true) c.mq.SendMessage(mq.EncodingController, mq.Footer, &dto.UpdateStatus{Message: "Re-encoding mp3 files..."}, false) c.mq.SendMessage(mq.EncodingController, mq.Footer, &dto.SetBusyIndicator{Busy: true}, false) // re-encode files - c.stopFlag = false - c.files = make([]fileEncode, len(c.ab.Mp3Files)) jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentEncoders()) for i, f := range c.ab.Mp3Files { - jd.AddJob(i, c.encodeFile, i, c.ab.OutputDir, f.FileName) + c.files[i].fileId = i + c.files[i].fileName = f.FileName + c.files[i].filePath = filepath.Join(c.ab.OutputDir, f.FileName) + mp3, _ := ffmpeg.NewFFProbe(c.files[i].filePath) + c.files[i].totalDuration = mp3.Duration() + + jd.AddJob(i, c.encodeFile, i, c.ab.OutputDir) } go c.updateTotalProgress() - // if c.stopFlag { - // break - // } - jd.Start() c.stopFlag = true @@ -90,17 +99,18 @@ func (c *EncodingController) startEncoding(cmd *dto.EncodeCommand) { c.mq.SendMessage(mq.EncodingController, mq.EncodingPage, &dto.EncodingComplete{Audiobook: cmd.Audiobook}, true) } -func (c *EncodingController) encodeFile(fileId int, outputDir string, fileName string) { +func (c *EncodingController) encodeFile(fileId int, outputDir string) { + if c.stopFlag { + return + } - filePath := filepath.Join(outputDir, fileName) + filePath := c.files[fileId].filePath tmpFile := filePath + ".tmp" - mp3, _ := ffmpeg.NewFFProbe(filePath) - duration := mp3.Duration() // launch progress listener l, port := c.startProgressListener(fileId) defer l.Close() - go c.updateFileProgress(fileId, fileName, duration, l) + go c.updateFileProgress(fileId, l) // launch ffmpeg process _, err := ffmpeg.NewFFmpeg(). @@ -123,10 +133,7 @@ func (c *EncodingController) encodeFile(fileId int, outputDir string, fileName s } func (c *EncodingController) startProgressListener(fileId int) (net.Listener, int) { - - basePortNumber := 31000 - portNumber := basePortNumber + fileId - + portNumber := config.Instance().GetBasePortNumber() + fileId l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(portNumber)) if err != nil { logger.Error("Encoding progress: Start listener error: " + err.Error()) @@ -134,95 +141,88 @@ func (c *EncodingController) startProgressListener(fileId int) (net.Listener, in return l, portNumber } -func (c *EncodingController) updateFileProgress(fileId int, fileName string, totalDuration float64, l net.Listener) { - - re := regexp.MustCompile(`out_time_ms=(\d+)`) +func (c *EncodingController) updateFileProgress(fileId int, l net.Listener) { fd, err := l.Accept() if err != nil { - return // listener is closed + return // listener may be closed already } buf := make([]byte, 16) data := "" percent := 0 - for { + + for !c.stopFlag { _, err := fd.Read(buf) if err != nil { return // listener is closed } data += string(buf) - a := re.FindAllStringSubmatch(data, -1) - p := 0 - pstr := "" - if len(a) > 0 && len(a[len(a)-1]) > 0 { - c, _ := strconv.Atoi(a[len(a)-1][len(a[len(a)-1])-1]) - pstr = fmt.Sprintf("%.2f", float64(c)/totalDuration/1000000) - } - if strings.Contains(data, "progress=end") { - p = 100 - } - if pstr == "" { - p = 0 + bytesProcessed, secondsProcessed, encodingSpeed, complete := ffmpeg.ParseFFMPEGProgress(data) + percent = int(secondsProcessed / c.files[fileId].totalDuration * 100) + // wrong calculation protection + if percent < 0 { + percent = 0 + } else if percent > 100 { + percent = 100 + } else if percent < c.files[fileId].progress { + percent = c.files[fileId].progress + } else if complete { + percent = 100 } - pflt, err := strconv.ParseFloat(pstr, 64) - if err != nil { - p = 0 - } else { - p = int(pflt * 100) - } - - if p != percent { - percent = p - - // wrong calculation protection - if percent < 0 { - percent = 0 - } else if percent > 100 { - percent = 100 - } - // sent a message only if progress changed - c.mq.SendMessage(mq.EncodingController, mq.EncodingPage, &dto.EncodingFileProgress{FileId: fileId, FileName: fileName, Percent: percent}, true) + // sent a message only if progress changed + if percent != c.files[fileId].progress { + c.files[fileId].bytesProcessed = bytesProcessed + c.files[fileId].secondsProcessed = secondsProcessed + c.files[fileId].encodingSpeed = encodingSpeed + c.files[fileId].progress = percent + c.files[fileId].complete = complete + c.mq.SendMessage(mq.EncodingController, mq.EncodingPage, &dto.EncodingFileProgress{FileId: fileId, FileName: c.files[fileId].fileName, Percent: percent}, true) } - c.files[fileId].fileId = fileId - c.files[fileId].progress = percent } } func (c *EncodingController) updateTotalProgress() { - var percent int = -1 - - for !c.stopFlag && percent <= 100 { - var totalPercent int = 0 - files := 0 + var p int = -1 + + for !c.stopFlag { + var totalDuration float64 = 0 + var secondsProcessed float64 = 0 + var totalSpeed float64 = 0 + filesProcessed := 0 + filesComplete := 0 for _, f := range c.files { - totalPercent += f.progress - if f.progress == 100 { - files++ + totalDuration += f.totalDuration + secondsProcessed += f.secondsProcessed + totalSpeed += f.encodingSpeed + if f.complete { + filesComplete++ + } + if f.encodingSpeed > 0 { + filesProcessed++ } } - p := int(totalPercent / len(c.files)) + percent := int(secondsProcessed / totalDuration * 100) + // wrong calculation protection + if percent < 0 { + percent = 0 + } else if percent > 100 { + percent = 100 + } if percent != p { // sent a message only if progress changed - percent = p - - // wrong calculation protection - if percent < 0 { - percent = 0 - } else if percent > 100 { - percent = 100 - } + p = percent elapsed := time.Since(c.startTime).Seconds() - speed := int64(float64(percent) / elapsed) + speed := totalSpeed / float64(filesProcessed) eta := (100 / (float64(percent) / elapsed)) - elapsed if eta < 0 || eta > (60*60*24*365) { eta = 0 } elapsedH := utils.SecondsToTime(elapsed) - filesH := fmt.Sprintf("%d/%d", files, len(c.ab.Mp3Files)) - speedH := utils.SpeedToHuman(speed) + filesH := fmt.Sprintf("%d/%d", filesComplete, len(c.ab.Mp3Files)) + speedH := fmt.Sprintf("%.0fx", speed) etaH := utils.SecondsToTime(eta) c.mq.SendMessage(mq.EncodingController, mq.EncodingPage, &dto.EncodingProgress{Elapsed: elapsedH, Percent: percent, Files: filesH, Speed: speedH, ETA: etaH}, true) diff --git a/internal/ffmpeg/utils.go b/internal/ffmpeg/utils.go index 758bb8e..5f4f02e 100644 --- a/internal/ffmpeg/utils.go +++ b/internal/ffmpeg/utils.go @@ -2,6 +2,8 @@ package ffmpeg import ( "os/exec" + "regexp" + "strconv" "strings" ) @@ -54,3 +56,44 @@ func ExitErr(e error) *exitErr { func (e *exitErr) Error() string { return e.errMessage } + +// parse ffmpeg progress stats +var reTotalSize = regexp.MustCompile(`total_size=(\d+)`) +var reOutTimeUs = regexp.MustCompile(`out_time_us=(\d+)`) +var reSpeed = regexp.MustCompile(`speed=\s*(\d+)x`) +var reComplete = regexp.MustCompile(`progress=end`) + +func ParseFFMPEGProgress(data string) (int64, float64, float64, bool) { + + var bytesProcessed int64 = 0 + var secondsProcessed float64 = 0 + var encodingSpeed float64 = 0 + var complete = false + + totalSizeMatches := reTotalSize.FindAllStringSubmatch(data, -1) + if len(totalSizeMatches) > 0 { + lastTotalSize := totalSizeMatches[len(totalSizeMatches)-1][1] + bytesProcessed, _ = strconv.ParseInt(lastTotalSize, 10, 64) + } + + outTimeUsMatches := reOutTimeUs.FindAllStringSubmatch(data, -1) + if len(outTimeUsMatches) > 0 { + lastOutTimeUs := outTimeUsMatches[len(outTimeUsMatches)-1][1] + msecProcessed, _ := strconv.ParseFloat(lastOutTimeUs, 64) + secondsProcessed = msecProcessed / 1000000 + } + + speedMatches := reSpeed.FindAllStringSubmatch(data, -1) + if len(speedMatches) > 0 { + lastSpeed := speedMatches[len(speedMatches)-1][1] + encodingSpeed, _ = strconv.ParseFloat(lastSpeed, 64) + + } + + completeMatches := reComplete.FindAllStringSubmatch(data, -1) + if len(completeMatches) > 0 { + complete = true + } + + return bytesProcessed, secondsProcessed, encodingSpeed, complete +} diff --git a/internal/ui/buildPage.go b/internal/ui/buildPage.go index 7481e26..abe1753 100644 --- a/internal/ui/buildPage.go +++ b/internal/ui/buildPage.go @@ -197,7 +197,7 @@ func (p *BuildPage) updateTotalBuildProgress(dp *dto.BuildProgress) { } infoCell := p.progressTable.t.GetCell(0, 0) progressCell := p.progressTable.t.GetCell(1, 0) - infoCell.Text = fmt.Sprintf(" [yellow]Time elapsed: [white]%10s | [yellow]Files: [white]%10s | [yellow]Speed: [white]%12s | [yellow]ETA: [white]%10s", dp.Elapsed, dp.Files, dp.Speed, dp.ETA) + infoCell.Text = fmt.Sprintf(" [yellow]Time elapsed: [white]%10s | [yellow]Files: [white]%10s | [yellow]Speed: [white]%10s | [yellow]ETA: [white]%10s", dp.Elapsed, dp.Files, dp.Speed, dp.ETA) col := 0 w := p.progressTable.getColumnWidth(col) - 5 diff --git a/internal/ui/encodingPage.go b/internal/ui/encodingPage.go index 1e447a0..bc2e09d 100644 --- a/internal/ui/encodingPage.go +++ b/internal/ui/encodingPage.go @@ -157,7 +157,7 @@ func (p *EncodingPage) updateTotalProgress(dp *dto.EncodingProgress) { } infoCell := p.progressTable.t.GetCell(0, 0) progressCell := p.progressTable.t.GetCell(1, 0) - infoCell.Text = fmt.Sprintf(" [yellow]Time elapsed: [white]%10s | [yellow]Files: [white]%10s | [yellow]Speed: [white]%12s | [yellow]ETA: [white]%10s", dp.Elapsed, dp.Files, dp.Speed, dp.ETA) + infoCell.Text = fmt.Sprintf(" [yellow]Time elapsed: [white]%10s | [yellow]Files: [white]%10s | [yellow]Speed: [white]%10s | [yellow]ETA: [white]%10s", dp.Elapsed, dp.Files, dp.Speed, dp.ETA) col := 0 w := p.progressTable.getColumnWidth(col) - 5