From e2d45e58039fa85aec619355dba3e2f25a1da766 Mon Sep 17 00:00:00 2001 From: vpoluyaktov Date: Wed, 20 Dec 2023 18:59:48 -0800 Subject: [PATCH] Search result paging implemented --- .gitignore | 2 +- internal/config/config.go | 15 ++-- internal/controller/build_controller.go | 6 +- internal/controller/download_controller.go | 8 +-- internal/controller/search_controller.go | 80 +++++++++++++++++----- internal/dto/build.go | 13 ++-- internal/dto/download.go | 12 ++-- internal/dto/search.go | 30 ++++++-- internal/ia/client.go | 16 ++++- internal/ui/build_page.go | 9 +-- internal/ui/config_page.go | 8 +-- internal/ui/download_page.go | 8 +-- internal/ui/search_page.go | 51 ++++++++++---- internal/ui/wrappers.go | 43 +++++++++--- internal/utils/dumper.go | 10 +-- 15 files changed, 217 insertions(+), 94 deletions(-) diff --git a/.gitignore b/.gitignore index 31f2831..a75b0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ dist/ output/ __debug_bin* abb_ia.log -config.yaml +abb_ia.config.yaml diff --git a/internal/config/config.go b/internal/config/config.go index 10af4aa..c89e34d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "abb_ia/internal/logger" "abb_ia/internal/utils" + "gopkg.in/yaml.v3" ) @@ -17,7 +18,7 @@ var ( // global vars var ( - configFile = "config.yaml" + configFile = "abb_ia.config.yaml" appVersion, buildDate string repoOwner string = "vpoluyaktov" repoName string = "abb_ia" @@ -26,7 +27,7 @@ var ( // 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 type Config struct { SearchCondition string `yaml:"SearchCondition"` - SearchRowsMax int `yaml:"SearchRowsMax"` + RowsPerPage int `yaml:"RowsPerPage"` LogFileName string `yaml:"LogFileName"` OutputDir string `yaml:"Outputdir"` CopyToOutputDir bool `yaml:"CopyToOutputDir"` @@ -73,7 +74,7 @@ func Load() { config.CopyToOutputDir = true config.OutputDir = "/mnt/NAS/Audiobooks/Internet Archive" config.LogLevel = "INFO" - config.SearchRowsMax = 25 + config.RowsPerPage = 25 config.UseMock = false config.SaveMock = false config.SearchCondition = "" @@ -181,12 +182,12 @@ func (c *Config) GetLogLevel() string { return c.LogLevel } -func (c *Config) SetSearchRowsMax(r int) { - c.SearchRowsMax = r +func (c *Config) SetRowsPerPage(r int) { + c.RowsPerPage = r } -func (c *Config) GetSearchRowsMax() int { - return c.SearchRowsMax +func (c *Config) GetRowsPerPage() int { + return c.RowsPerPage } func (c *Config) SetUseMock(b bool) { diff --git a/internal/controller/build_controller.go b/internal/controller/build_controller.go index 132a2c5..681e414 100644 --- a/internal/controller/build_controller.go +++ b/internal/controller/build_controller.go @@ -230,7 +230,7 @@ func (c *BuildController) buildAudiobookPart(ab *dto.Audiobook, partId int) { return } - // clean up + // clean up os.Remove(part.AACFile) // add tags and cover image @@ -312,7 +312,7 @@ func (c *BuildController) updateFileProgress(fileId int, l net.Listener) { 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.mq.SendMessage(mq.BuildController, mq.BuildPage, &dto.FileBuildProgress{FileId: fileId, FileName: c.files[fileId].fileName, Percent: percent}, true) } } } @@ -361,7 +361,7 @@ func (c *BuildController) updateTotalProgress() { 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) + c.mq.SendMessage(mq.BuildController, mq.BuildPage, &dto.TotalBuildProgress{Elapsed: elapsedH, Percent: percent, Files: filesH, Speed: speedH, ETA: etaH}, true) } time.Sleep(mq.PullFrequency) } diff --git a/internal/controller/download_controller.go b/internal/controller/download_controller.go index 26d6422..07fad43 100644 --- a/internal/controller/download_controller.go +++ b/internal/controller/download_controller.go @@ -6,7 +6,7 @@ import ( "time" "abb_ia/internal/dto" - "abb_ia/internal/ia" + ia_client "abb_ia/internal/ia" "abb_ia/internal/logger" "abb_ia/internal/mq" "abb_ia/internal/utils" @@ -79,7 +79,7 @@ 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(c.ab.Config.GetSearchRowsMax(), c.ab.Config.IsUseMock(), c.ab.Config.IsSaveMock()) + ia := ia_client.New(c.ab.Config.GetRowsPerPage(), c.ab.Config.IsUseMock(), c.ab.Config.IsSaveMock()) c.stopFlag = false c.files = make([]fileDownload, len(item.AudioFiles)) jd := utils.NewJobDispatcher(c.ab.Config.GetConcurrentDownloaders()) @@ -111,7 +111,7 @@ func (c *DownloadController) updateFileProgress(fileId int, fileName string, siz } // sent a message only if progress changed - c.mq.SendMessage(mq.DownloadController, mq.DownloadPage, &dto.DownloadFileProgress{FileId: fileId, FileName: fileName, Percent: percent}, false) + c.mq.SendMessage(mq.DownloadController, mq.DownloadPage, &dto.FileDownloadProgress{FileId: fileId, FileName: fileName, Percent: percent}, false) } c.files[fileId].fileId = fileId c.files[fileId].fileSize = size @@ -169,7 +169,7 @@ func (c *DownloadController) updateTotalProgress() { speedH := utils.SpeedToHuman(speed) etaH := utils.SecondsToTime(eta) - c.mq.SendMessage(mq.DownloadController, mq.DownloadPage, &dto.DownloadProgress{Elapsed: elapsedH, Percent: percent, Files: filesH, Bytes: bytesH, Speed: speedH, ETA: etaH}, false) + c.mq.SendMessage(mq.DownloadController, mq.DownloadPage, &dto.TotalDownloadProgress{Elapsed: elapsedH, Percent: percent, Files: filesH, Bytes: bytesH, Speed: speedH, ETA: etaH}, false) } time.Sleep(mq.PullFrequency) } diff --git a/internal/controller/search_controller.go b/internal/controller/search_controller.go index aa203dd..2833f64 100644 --- a/internal/controller/search_controller.go +++ b/internal/controller/search_controller.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "net/url" "sort" "strconv" @@ -16,8 +17,17 @@ import ( "github.com/vpoluyaktov/tview" ) +var ( + // mp3 format list ranged by priority + Mp3Formats = []string{"16Kbps MP3", "24Kbps MP3", "32Kbps MP3", "40Kbps MP3", "48Kbps MP3", "56Kbps MP3", "64Kbps MP3", "80Kbps MP3", "96Kbps MP3", "112Kbps MP3", "128Kbps MP3", "144Kbps MP3", "160Kbps MP3", "224Kbps MP3", "256Kbps MP3", "320Kbps MP3", "VBR MP3"} + // audiobook cover formats + CoverFormats = []string{"JPEG", "JPEG Thumb"} +) + type SearchController struct { - mq *mq.Dispatcher + mq *mq.Dispatcher + ia *ia_client.IAClient + totalItemsFetched int } func NewSearchController(dispatcher *mq.Dispatcher) *SearchController { @@ -37,22 +47,57 @@ func (c *SearchController) checkMQ() { func (c *SearchController) dispatchMessage(m *mq.Message) { switch dto := m.Dto.(type) { case *dto.SearchCommand: - go c.performSearch(dto) + go c.search(dto) + case *dto.GetNextPageCommand: + go c.getGetNextPage(dto) default: m.UnsupportedTypeError(mq.SearchController) } } -func (c *SearchController) performSearch(cmd *dto.SearchCommand) { +func (c *SearchController) search(cmd *dto.SearchCommand) { + logger.Info(mq.SearchController + " received " + cmd.String()) + c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.UpdateStatus{Message: "Fetching Internet Archive items..."}, false) + c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.SetBusyIndicator{Busy: true}, false) + c.totalItemsFetched = 0 + c.ia = ia_client.New(config.Instance().GetRowsPerPage(), config.Instance().IsUseMock(), config.Instance().IsSaveMock()) + resp := c.ia.Search(cmd.SearchCondition, "audio") + if resp == nil { + logger.Error(mq.SearchController + ": Failed to perform IA search with condition: " + cmd.SearchCondition) + } + itemsFetched, err := c.fetchDetails(resp) + if err != nil { + logger.Error(mq.SearchController + ": Failed to fetch item details: " + err.Error()) + } + 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.SearchPage, &dto.SearchComplete{SearchCondition: cmd.SearchCondition}, false) + if itemsFetched == 0 { + c.mq.SendMessage(mq.SearchController, mq.SearchPage, &dto.NothingFoundError{SearchCondition: cmd.SearchCondition}, false) + } +} + +func (c *SearchController) getGetNextPage(cmd *dto.GetNextPageCommand) { logger.Info(mq.SearchController + " received " + cmd.String()) c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.UpdateStatus{Message: "Fetching Internet Archive items..."}, false) c.mq.SendMessage(mq.SearchController, mq.Footer, &dto.SetBusyIndicator{Busy: true}, false) - ia := ia_client.New(config.Instance().GetSearchRowsMax(), config.Instance().IsUseMock(), config.Instance().IsSaveMock()) - resp := ia.Search(cmd.SearchCondition, "audio") + resp := c.ia.GetNextPage(cmd.SearchCondition, "audio") if resp == nil { logger.Error(mq.SearchController + ": Failed to perform IA search with condition: " + cmd.SearchCondition) } + itemsFetched, err := c.fetchDetails(resp) + if err != nil { + logger.Error(mq.SearchController + ": Failed to fetch item details: " + err.Error()) + } + 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.SearchPage, &dto.SearchComplete{SearchCondition: cmd.SearchCondition}, false) + if itemsFetched == 0 { + c.mq.SendMessage(mq.SearchController, mq.SearchPage, &dto.LastPageMessage{SearchCondition: cmd.SearchCondition}, false) + } +} +func (c *SearchController) fetchDetails(resp *ia_client.SearchResponse) (int, error) { itemsTotal := resp.Response.NumFound itemsFetched := 0 @@ -67,7 +112,7 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { item.AudioFiles = make([]dto.AudioFile, 0) var totalSize int64 = 0 var totalLength float64 = 0.0 - d := ia.GetItemDetails(doc.Identifier) + d := c.ia.GetItemDetails(doc.Identifier) if d != nil { item.Server = d.Server item.Dir = d.Dir @@ -82,18 +127,19 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { } if len(d.Metadata.Description) > 0 { - item.Description = tview.Escape(ia.Html2Text(d.Metadata.Description[0])) + item.Description = tview.Escape(c.ia.Html2Text(d.Metadata.Description[0])) } for name, metadata := range d.Files { format := metadata.Format // collect mp3 files - if utils.Contains(dto.Mp3Formats, format) { + if utils.Contains(Mp3Formats, format) { size, sErr := strconv.ParseInt(metadata.Size, 10, 64) length, lErr := utils.TimeToSeconds(metadata.Length) if sErr != nil || lErr != nil { logger.Error("Can't parse the file metadata: " + name) + return 0, fmt.Errorf("can't parse file metadata: " + name) } else { file := dto.AudioFile{} file.Name = strings.TrimPrefix(name, "/") @@ -110,8 +156,8 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { addNewFile := true for i, oldFile := range item.AudioFiles { if file.Title == oldFile.Title { - oldFilePriority := utils.GetIndex(dto.Mp3Formats, oldFile.Format) - newFilePriority := utils.GetIndex(dto.Mp3Formats, file.Format) + oldFilePriority := utils.GetIndex(Mp3Formats, oldFile.Format) + newFilePriority := utils.GetIndex(Mp3Formats, file.Format) if newFilePriority > oldFilePriority { // remove old file from the list item.AudioFiles = append(item.AudioFiles[:i], item.AudioFiles[i+1:]...) @@ -137,7 +183,7 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { } // collect image files - if utils.Contains(dto.CoverFormats, format) { + if utils.Contains(CoverFormats, format) { size, err := strconv.ParseInt(metadata.Size, 10, 64) if err == nil { file := dto.ImageFile{} @@ -172,17 +218,13 @@ func (c *SearchController) performSearch(cmd *dto.SearchCommand) { if len(item.AudioFiles) > 0 { itemsFetched++ - sp := &dto.SearchProgress{ItemsTotal: itemsTotal, ItemsFetched: itemsFetched} + c.totalItemsFetched++ + sp := &dto.SearchProgress{ItemsTotal: itemsTotal, ItemsFetched: c.totalItemsFetched} c.mq.SendMessage(mq.SearchController, mq.SearchPage, sp, false) c.mq.SendMessage(mq.SearchController, mq.SearchPage, item, false) } } - 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) - - if itemsFetched == 0 { - c.mq.SendMessage(mq.SearchController, mq.SearchPage, &dto.NothingFoundError{SearchCondition: cmd.SearchCondition}, false) + logger.Debug(mq.SearchController + " fetched first " + strconv.Itoa(c.totalItemsFetched) + " items from " + strconv.Itoa(itemsTotal) + " total") } + return itemsFetched, nil } diff --git a/internal/dto/build.go b/internal/dto/build.go index 9f811f3..b8fd620 100644 --- a/internal/dto/build.go +++ b/internal/dto/build.go @@ -10,17 +10,17 @@ func (c *BuildCommand) String() string { return fmt.Sprintf("BuildCommand: %s", c.Audiobook.String()) } -type BuildFileProgress struct { +type FileBuildProgress struct { FileId int FileName string Percent int } -func (c *BuildFileProgress) String() string { - return fmt.Sprintf("BuildFileProgress: %d, %s, %d", c.FileId, c.FileName, c.Percent) +func (c *FileBuildProgress) String() string { + return fmt.Sprintf("FileBuildProgress: %d, %s, %d", c.FileId, c.FileName, c.Percent) } -type BuildProgress struct { +type TotalBuildProgress struct { Elapsed string // time since started Percent int Files string // files encoded @@ -28,11 +28,10 @@ type BuildProgress struct { ETA string // ETA in seconds } -func (c *BuildProgress) String() string { - return fmt.Sprintf("BuildProgress: %d", c.Percent) +func (c *TotalBuildProgress) String() string { + return fmt.Sprintf("TotalBuildProgress: %d", c.Percent) } - type BuildComplete struct { Audiobook *Audiobook } diff --git a/internal/dto/download.go b/internal/dto/download.go index 3ff0934..e592809 100644 --- a/internal/dto/download.go +++ b/internal/dto/download.go @@ -18,17 +18,17 @@ func (c *DisplayBookInfoCommand) String() string { return fmt.Sprintf("DisplayBookInfoCommand: %s", c.Audiobook.String()) } -type DownloadFileProgress struct { +type FileDownloadProgress struct { FileId int FileName string Percent int } -func (c *DownloadFileProgress) String() string { - return fmt.Sprintf("DownloadFileProgress: %d, %s, %d", c.FileId, c.FileName, c.Percent) +func (c *FileDownloadProgress) String() string { + return fmt.Sprintf("FileDownloadProgress: %d, %s, %d", c.FileId, c.FileName, c.Percent) } -type DownloadProgress struct { +type TotalDownloadProgress struct { Elapsed string // time since started Percent int Files string // files downloaded @@ -37,8 +37,8 @@ type DownloadProgress struct { ETA string // ETA in seconds } -func (c *DownloadProgress) String() string { - return fmt.Sprintf("DownloadProgress: %d", c.Percent) +func (c *TotalDownloadProgress) String() string { + return fmt.Sprintf("TotalDownloadProgress: %d", c.Percent) } type DownloadComplete struct { diff --git a/internal/dto/search.go b/internal/dto/search.go index 78799ad..4088e19 100644 --- a/internal/dto/search.go +++ b/internal/dto/search.go @@ -2,12 +2,6 @@ package dto import "fmt" -// format list ranged by priority -var Mp3Formats = []string{"16Kbps MP3", "24Kbps MP3", "32Kbps MP3", "40Kbps MP3", "48Kbps MP3", "56Kbps MP3", "64Kbps MP3", "80Kbps MP3", "96Kbps MP3", "112Kbps MP3", "128Kbps MP3", "144Kbps MP3", "160Kbps MP3", "224Kbps MP3", "256Kbps MP3", "320Kbps MP3", "VBR MP3"} - -// -var CoverFormats = []string{"JPEG", "JPEG Thumb"} - type SearchCommand struct { SearchCondition string } @@ -16,6 +10,22 @@ func (c *SearchCommand) String() string { return fmt.Sprintf("SearchCommand: %s", c.SearchCondition) } +type GetNextPageCommand struct { + SearchCondition string +} + +func (c *GetNextPageCommand) String() string { + return fmt.Sprintf("GetNextPageCommand: %s", c.SearchCondition) +} + +type SearchComplete struct { + SearchCondition string +} + +func (c *SearchComplete) String() string { + return fmt.Sprintf("SearchComplete: %s", c.SearchCondition) +} + type NothingFoundError struct { SearchCondition string } @@ -24,6 +34,14 @@ func (c *NothingFoundError) String() string { return fmt.Sprintf("NothingFoundError: %s", c.SearchCondition) } +type LastPageMessage struct { + SearchCondition string +} + +func (c *LastPageMessage) String() string { + return fmt.Sprintf("LastPageMessage: %s", c.SearchCondition) +} + type SearchProgress struct { ItemsTotal int ItemsFetched int diff --git a/internal/ia/client.go b/internal/ia/client.go index 02d1acf..2184a4d 100644 --- a/internal/ia/client.go +++ b/internal/ia/client.go @@ -24,6 +24,7 @@ const ( type IAClient struct { restyClient *resty.Client maxSearchRows int + page int loadMockResult bool saveMockResult bool } @@ -48,10 +49,21 @@ func (client *IAClient) Search(searchCondition string, mediaType string) *Search item_id := strings.Split(searchCondition, "/")[4] return client.searchByID(item_id, mediaType) } else { + client.page = 1 return client.searchByTitle(searchCondition, mediaType) } } +func (client *IAClient) GetNextPage(searchCondition string, mediaType string) *SearchResponse { + if strings.Contains(searchCondition, IA_BASE_URL+"/details/") { + return &SearchResponse{} + } else { + client.page += 1 + resp := client.searchByTitle(searchCondition, mediaType) + return resp + } +} + func (client *IAClient) searchByTitle(title string, mediaType string) *SearchResponse { mockFile := MOCK_DIR + "/SearchByTitle.json" result := &SearchResponse{} @@ -60,8 +72,8 @@ func (client *IAClient) searchByTitle(title string, mediaType string) *SearchRes logger.Error("IA Client SearchByTitle() mock load error: " + err.Error()) } } else { - var searchURL = fmt.Sprintf(IA_BASE_URL+"/advancedsearch.php?q=title:(%s)+AND+mediatype:(%s)&output=json&rows=%d&page=1", - url.QueryEscape(title), mediaType, client.maxSearchRows) + var searchURL = fmt.Sprintf(IA_BASE_URL+"/advancedsearch.php?q=title:(%s)+AND+mediatype:(%s)&output=json&rows=%d&page=%d", + url.QueryEscape(title), mediaType, client.maxSearchRows, client.page) logger.Debug("IA request: " + searchURL) _, err := client.restyClient.R().SetResult(result).Get(searchURL) if err != nil { diff --git a/internal/ui/build_page.go b/internal/ui/build_page.go index b9f9409..9a89b54 100644 --- a/internal/ui/build_page.go +++ b/internal/ui/build_page.go @@ -127,9 +127,9 @@ func (p *BuildPage) dispatchMessage(m *mq.Message) { switch dto := m.Dto.(type) { case *dto.DisplayBookInfoCommand: p.displayBookInfo(dto.Audiobook) - case *dto.BuildFileProgress: + case *dto.FileBuildProgress: p.updateFileBuildProgress(dto) - case *dto.BuildProgress: + case *dto.TotalBuildProgress: p.updateTotalBuildProgress(dto) case *dto.BuildComplete: p.buildComplete(dto) @@ -225,7 +225,7 @@ func (p *BuildPage) stopBuild() { p.switchToSearch() } -func (p *BuildPage) updateFileBuildProgress(dp *dto.BuildFileProgress) { +func (p *BuildPage) updateFileBuildProgress(dp *dto.FileBuildProgress) { col := 5 w := p.buildTable.GetColumnWidth(col) - 5 if w > 0 { @@ -246,12 +246,13 @@ func (p *BuildPage) updateFileBuildProgress(dp *dto.BuildFileProgress) { } } -func (p *BuildPage) updateTotalBuildProgress(dp *dto.BuildProgress) { +func (p *BuildPage) updateTotalBuildProgress(dp *dto.TotalBuildProgress) { if p.progressTable.GetRowCount() == 0 { for i := 0; i < 2; i++ { p.progressTable.appendRow("") } } + p.progressSection.SetTitle(" Build progress: ") infoCell := p.progressTable.GetCell(0, 0) progressCell := p.progressTable.GetCell(1, 0) 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) diff --git a/internal/ui/config_page.go b/internal/ui/config_page.go index f49bcde..0053908 100644 --- a/internal/ui/config_page.go +++ b/internal/ui/config_page.go @@ -26,7 +26,7 @@ type ConfigPage struct { logFileNameField *tview.InputField logLevelField *tview.DropDown searchCondition *tview.InputField - maxSearchRows *tview.InputField + rowsPerPage *tview.InputField useMockField *tview.Checkbox saveMockField *tview.Checkbox outputDir *tview.InputField @@ -73,7 +73,7 @@ func newConfigPage(dispatcher *mq.Dispatcher) *ConfigPage { configFormLeft := newForm() configFormLeft.SetHorizontal(false) 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.rowsPerPage = configFormLeft.AddInputField("Rows per a page in the search result:", "", 4, acceptInt, func(t string) { p.configCopy.SetRowsPerPage(utils.ToInt(t)) }) 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.configSection.AddItem(configFormLeft.Form, 0, 0, 1, 1, 0, 0, true) @@ -146,7 +146,7 @@ func newConfigPage(dispatcher *mq.Dispatcher) *ConfigPage { // screen navigation order p.mainGrid.SetNavigationOrder( p.searchCondition, - p.maxSearchRows, + p.rowsPerPage, p.useMockField, p.saveMockField, p.outputDir, @@ -199,7 +199,7 @@ func (p *ConfigPage) displayConfig(c *dto.DisplayConfigCommand) { p.logFileNameField.SetText(p.configCopy.GetLogFileName()) p.logLevelField.SetCurrentOption(utils.GetIndex(logger.LogLeves(), p.configCopy.GetLogLevel())) p.searchCondition.SetText(p.configCopy.GetSearchCondition()) - p.maxSearchRows.SetText(utils.ToString(p.configCopy.GetSearchRowsMax())) + p.rowsPerPage.SetText(utils.ToString(p.configCopy.GetRowsPerPage())) p.useMockField.SetChecked(p.configCopy.IsUseMock()) p.saveMockField.SetChecked(p.configCopy.IsSaveMock()) diff --git a/internal/ui/download_page.go b/internal/ui/download_page.go index d50a5eb..92bb42d 100644 --- a/internal/ui/download_page.go +++ b/internal/ui/download_page.go @@ -96,9 +96,9 @@ func (p *DownloadPage) dispatchMessage(m *mq.Message) { switch dto := m.Dto.(type) { case *dto.DisplayBookInfoCommand: p.displayBookInfo(dto.Audiobook) - case *dto.DownloadFileProgress: + case *dto.FileDownloadProgress: p.updateFileProgress(dto) - case *dto.DownloadProgress: + case *dto.TotalDownloadProgress: p.updateTotalProgress(dto) case *dto.DownloadComplete: p.downloadComplete(dto) @@ -137,7 +137,7 @@ func (p *DownloadPage) stopDownload() { p.mq.SendMessage(mq.DownloadPage, mq.Frame, &dto.SwitchToPageCommand{Name: "SearchPage"}, false) } -func (p *DownloadPage) updateFileProgress(dp *dto.DownloadFileProgress) { +func (p *DownloadPage) updateFileProgress(dp *dto.FileDownloadProgress) { col := 5 w := p.filesTable.GetColumnWidth(col) - 5 if w > 0 { @@ -158,7 +158,7 @@ func (p *DownloadPage) updateFileProgress(dp *dto.DownloadFileProgress) { } } -func (p *DownloadPage) updateTotalProgress(dp *dto.DownloadProgress) { +func (p *DownloadPage) updateTotalProgress(dp *dto.TotalDownloadProgress) { if p.progressTable.GetRowCount() == 0 { for i := 0; i < 2; i++ { p.progressTable.appendRow("") diff --git a/internal/ui/search_page.go b/internal/ui/search_page.go index fd11f3e..7cc2607 100644 --- a/internal/ui/search_page.go +++ b/internal/ui/search_page.go @@ -14,10 +14,11 @@ import ( ) type SearchPage struct { - mq *mq.Dispatcher - mainGrid *grid - searchCriteria string - searchResult []*dto.IAItem + mq *mq.Dispatcher + mainGrid *grid + searchCriteria string + isSearchRunning bool + searchResult []*dto.IAItem searchSection *grid inputField *tview.InputField @@ -74,10 +75,11 @@ func newSearchPage(dispatcher *mq.Dispatcher) *SearchPage { p.resultSection.SetBorder(true) p.resultTable = newTable() - p.resultTable.setHeaders("Author", "Title", "Files", "Duration (hh:mm:ss)", "Total Size") - p.resultTable.setWeights(3, 6, 2, 1, 1) - p.resultTable.setAlign(tview.AlignLeft, tview.AlignLeft, tview.AlignRight, tview.AlignRight, tview.AlignRight) + p.resultTable.setHeaders(" # ", "Author", "Title", "Files", "Duration (hh:mm:ss)", "Total Size") + p.resultTable.setWeights(1, 3, 6, 2, 1, 1) + p.resultTable.setAlign(tview.AlignRight, tview.AlignLeft, tview.AlignLeft, tview.AlignRight, tview.AlignRight, tview.AlignRight) p.resultTable.SetSelectionChangedFunc(p.updateDetails) + p.resultTable.setLastRowEvent(p.lastRowEvent) p.resultSection.AddItem(p.resultTable.Table, 0, 0, 1, 1, 0, 0, true) p.mainGrid.AddItem(p.resultSection.Grid, 1, 0, 1, 1, 0, 0, true) @@ -137,11 +139,15 @@ func (p *SearchPage) checkMQ() { func (p *SearchPage) dispatchMessage(m *mq.Message) { switch dto := m.Dto.(type) { case *dto.IAItem: - go p.updateResult(dto) + p.updateResult(dto) case *dto.SearchProgress: p.updateTitle(dto) + case *dto.SearchComplete: + p.isSearchRunning = false case *dto.NothingFoundError: p.showNothingFoundError(dto) + case *dto.LastPageMessage: + p.showLastPageMessage(dto) case *dto.NewAppVersionFound: p.showNewVersionMessage(dto) case *dto.FFMPEGNotFoundError: @@ -152,9 +158,12 @@ func (p *SearchPage) dispatchMessage(m *mq.Message) { } func (p *SearchPage) runSearch() { + if p.isSearchRunning { + return + } + p.isSearchRunning = true p.clearSearchResults() p.resultTable.showHeader() - // Disable Search Button here p.mq.SendMessage(mq.SearchPage, mq.SearchController, &dto.SearchCommand{SearchCondition: p.searchCriteria}, false) p.mq.SendMessage(mq.SearchPage, mq.TUI, &dto.SetFocusCommand{Primitive: p.resultTable.Table}, true) } @@ -172,17 +181,25 @@ func (p *SearchPage) clearEverything() { p.clearSearchResults() } +func (p *SearchPage) lastRowEvent() { + if p.isSearchRunning { + return + } + p.isSearchRunning = true + p.mq.SendMessage(mq.SearchPage, mq.SearchController, &dto.GetNextPageCommand{SearchCondition: p.searchCriteria}, false) +} + func (p *SearchPage) updateResult(i *dto.IAItem) { logger.Debug(mq.SearchPage + ": Got AI Item: " + i.Title) p.searchResult = append(p.searchResult, i) - p.resultTable.appendRow(i.Creator, i.Title, strconv.Itoa(len(i.AudioFiles)), utils.SecondsToTime(i.TotalLength), utils.BytesToHuman(i.TotalSize)) - p.resultTable.ScrollToBeginning() - p.updateDetails(1, 0) + row, col := p.resultTable.GetSelection() + p.resultTable.appendRow(strconv.Itoa(p.resultTable.GetRowCount()), i.Creator, i.Title, strconv.Itoa(len(i.AudioFiles)), utils.SecondsToTime(i.TotalLength), utils.BytesToHuman(i.TotalSize)) + p.resultTable.Select(row, col) ui.Draw() } func (p *SearchPage) updateTitle(sp *dto.SearchProgress) { - p.resultSection.SetTitle(fmt.Sprintf(" Search result (first %d items from %d total): ", sp.ItemsFetched, sp.ItemsTotal)) + p.resultSection.SetTitle(fmt.Sprintf(" Search result (fetched %d items from %d total): ", sp.ItemsFetched, sp.ItemsTotal)) } func (p *SearchPage) updateDetails(row int, col int) { @@ -253,6 +270,14 @@ func (p *SearchPage) showNothingFoundError(dto *dto.NothingFoundError) { p.searchSection.Grid, func() {}) } +func (p *SearchPage) showLastPageMessage(dto *dto.LastPageMessage) { + newMessageDialog(p.mq, "Notification", + "No more items were found for \n"+ + "your search term: [darkblue]'"+dto.SearchCondition+"'[black].\n"+ + "This is the last page.\n", + p.resultSection.Grid, func() {}) +} + func (p *SearchPage) showFFMPEGNotFoundError(dto *dto.FFMPEGNotFoundError) { newMessageDialog(p.mq, "Error", "This application requires the utilities [darkblue]ffmpeg[black] and [darkblue]ffprobe[black].\n"+ diff --git a/internal/ui/wrappers.go b/internal/ui/wrappers.go index 12b4519..ccf69c0 100644 --- a/internal/ui/wrappers.go +++ b/internal/ui/wrappers.go @@ -118,10 +118,12 @@ func (g *grid) getFocusIndex() (int, error) { // ////////////////////////////////////////////////////////////// type table struct { *tview.Table - headers []string - colWeight []int - colWidth []int - aligns []uint + mu sync.Mutex + headers []string + colWeight []int + colWidth []int + aligns []uint + lastRowEvent func() } func newTable() *table { @@ -136,9 +138,24 @@ func newTable() *table { t.Table.SetBorder(false) t.Table.Clear() t.Table.SetEvaluateAllRows(true) + t.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyDown { + row, _ := t.GetSelection() + if row == t.GetRowCount()-1 { + if t.lastRowEvent != nil { + t.lastRowEvent() + } + } + } + return event + }) return t } +func (t *table) setLastRowEvent(fn func()) { + t.lastRowEvent = fn +} + // Mouse Double Click func (t *table) SetMouseDblClickFunc(f func(row, column int)) { t.Table.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { @@ -172,8 +189,10 @@ func (t *table) setAlign(aligns ...uint) { func (t *table) showHeader() { if len(t.colWeight) == 0 || len(t.colWeight) != len(t.headers) { - return + return } + t.mu.Lock() + defer t.mu.Unlock() for col, h := range t.headers { cell := tview.NewTableCell(h) cell.SetTextColor(yellow) @@ -189,9 +208,11 @@ func (t *table) showHeader() { } func (t *table) appendRow(cols ...string) { - if len(t.colWeight) == 0 { - return + if len(t.colWeight) == 0 { + return } + t.mu.Lock() + defer t.mu.Unlock() row := t.GetRowCount() for col, val := range cols { cell := tview.NewTableCell(val) @@ -203,9 +224,11 @@ func (t *table) appendRow(cols ...string) { } func (t *table) appendSeparator(cols ...string) { - if len(t.colWeight) == 0 { - return + if len(t.colWeight) == 0 { + return } + t.mu.Lock() + defer t.mu.Unlock() row := t.GetRowCount() for col, val := range cols { cell := tview.NewTableCell(val) @@ -224,6 +247,8 @@ func (t *table) recalculateColumnWidths() { if len(t.colWeight) == 0 { return } + t.mu.Lock() + defer t.mu.Unlock() allWeights := 0 for _, w := range t.colWeight { allWeights += w diff --git a/internal/utils/dumper.go b/internal/utils/dumper.go index 93ddcc9..65ea430 100644 --- a/internal/utils/dumper.go +++ b/internal/utils/dumper.go @@ -8,7 +8,7 @@ import ( "sync" ) -var lock sync.Mutex +var mu sync.Mutex var Marshal = func(obj interface{}) (io.Reader, error) { b, err := json.MarshalIndent(obj, "", " ") @@ -24,8 +24,8 @@ var Unmarshal = func(r io.Reader, obj interface{}) error { // Save saves a representation of object to the file at path. func DumpJson(file string, obj interface{}) error { - lock.Lock() - defer lock.Unlock() + mu.Lock() + defer mu.Unlock() f, err := os.Create(file) if err != nil { return err @@ -41,8 +41,8 @@ func DumpJson(file string, obj interface{}) error { // loads the file at path into obj. func LoadJson(file string, obj interface{}) error { - lock.Lock() - defer lock.Unlock() + mu.Lock() + defer mu.Unlock() f, err := os.Open(file) if err != nil { return err