From b4510f84ebff347e57e5cf0f08c6abdf88867a69 Mon Sep 17 00:00:00 2001 From: Jesse Stuart Date: Sat, 9 Sep 2023 19:23:50 -0400 Subject: [PATCH] match local books by info.txt --- audible/library.go | 30 +++++-- cli/cli.go | 7 +- gui/library/library.go | 3 + internal/common/common.go | 163 ++++++++++++++++++++++++++++++-------- 4 files changed, 162 insertions(+), 41 deletions(-) diff --git a/audible/library.go b/audible/library.go index d3d598c..b0d5563 100644 --- a/audible/library.go +++ b/audible/library.go @@ -2,8 +2,6 @@ package audible import ( "context" - "crypto/sha1" - "encoding/hex" "fmt" "image" "io" @@ -37,12 +35,11 @@ type Book struct { } func (b *Book) ID() string { - h := sha1.New() - for _, name := range b.Authors { - io.WriteString(h, name) + parts := strings.Split(b.AudibleURL, "/") + if len(parts) == 0 { + return b.AudibleURL } - io.WriteString(h, b.Title) - return hex.EncodeToString(h.Sum(nil)) + return parts[len(parts)-1] } func (b *Book) WriteInfo(w io.Writer) error { @@ -220,7 +217,7 @@ func (c *Client) getLibraryPage(ctx context.Context, pageURL string) (*Page, err if u, err := url.Parse(htmlquery.SelectAttr(a, "href")); err == nil { u = resp.Request.URL.ResolveReference(u) u.RawQuery = "" // query is unnecessary baggage - book.AudibleURL = u.String() + book.AudibleURL = strings.Split(u.String(), "?")[0] } } book.Title = strings.TrimSpace(htmlquery.InnerText(node)) @@ -283,6 +280,23 @@ func (c *Client) getLibraryPage(ctx context.Context, pageURL string) (*Page, err addDownloadURL(a) } + if book.AudibleURL == "" && len(book.DownloadURLs) > 0 { + for _, du := range book.DownloadURLs { + log.Debugf("using download URL (%s) for AudibleURL", du) + if u, err := url.Parse(du); err == nil { + q := u.Query() + if asin := q.Get("asin"); asin != "" { + book.AudibleURL = fmt.Sprintf("https://audible.com/pd/%s", asin) + } else { + log.Debugf("unable to find asin in url(%s)", du) + } + } else { + log.Debugf("error parsing url(%s): %v", du, err) + } + break + } + } + page.Books = append(page.Books, book) } diff --git a/cli/cli.go b/cli/cli.go index 42f9ee8..ccef483 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -109,7 +109,7 @@ func (cli *CLI) GetNewBooks(books []*audible.Book) ([]*audible.Book, []*audible. localBooksByID[b.ID()] = b } - newBooks := make([]*audible.Book, 0, len(books)-len(localBooks)) + newBooks := make([]*audible.Book, 0) downloadedBooks := make([]*audible.Book, 0, len(localBooks)) for _, b := range books { if dlb, ok := localBooksByID[b.ID()]; ok { @@ -165,7 +165,10 @@ func (cli *CLI) DownloadLibrary(ctx context.Context, c *audible.Client) error { getDstPath := PromptPathTemplate() for _, b := range books { - b.LocalPath = filepath.Join(cli.DstDir, getDstPath(b)) + p := filepath.Join(cli.DstDir, getDstPath(b)) + if b.LocalPath == "" { + b.LocalPath = p + } } loop: diff --git a/gui/library/library.go b/gui/library/library.go index e976837..7638090 100644 --- a/gui/library/library.go +++ b/gui/library/library.go @@ -216,6 +216,7 @@ func SetSelectedDir(uri fyne.ListableURI) Action { if err != nil { log.Errorf("Error discovering downloaded books: %s", err) } + log.Debugf("Found %d local books", len(localBooks)) n := s.numSelected for _, b := range localBooks { if bi, ok := s.GetBookIndexForID(b.ID()); ok { @@ -237,6 +238,8 @@ func SetSelectedDir(uri fyne.ListableURI) Action { ch <- components.CheckboxActionSetChecked(false) ch <- components.CheckboxActionDisable() } + } else { + log.Debugf("unable to match local book: %s (%s)", b.ID(), b.Title) } } s.SetNumSelected(n) diff --git a/internal/common/common.go b/internal/common/common.go index cecfee1..fb064c4 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -1,9 +1,12 @@ package common import ( + "bufio" "bytes" "context" + "fmt" "image" + "io" "os" "os/signal" "path/filepath" @@ -81,14 +84,62 @@ func CompilePathTemplate(t string, subs ...PathTemplateSub) func(b *audible.Book } } -func ListDownloadedBooks(dir string) ([]*audible.Book, error) { - parseAuthors := func(str string) []string { - parts := strings.Split(str, ",") - for i, p := range parts { - parts[i] = strings.TrimSpace(p) +type bookInfo struct { + Title string + Authors []string + Narrators []string + URL string +} + +func parseInfoTxt(data io.Reader) (*bookInfo, error) { + info := &bookInfo{} + + s := bufio.NewScanner(data) + + // the first line is the title + s.Scan() + info.Title = s.Text() + + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "Written by:") { + info.Authors = strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Written by:")), ", ") + continue + } + if strings.HasPrefix(line, "Narrated by:") { + info.Narrators = strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Narrated by:")), ", ") + continue + } + if strings.HasPrefix(line, "URL:") { + info.URL = strings.TrimSpace(strings.TrimPrefix(line, "URL:")) + continue } - return parts } + + if len(info.Title) == 0 { + return nil, fmt.Errorf("invalid info.txt: missing book title") + } + + if len(info.Authors) == 0 { + return nil, fmt.Errorf("invalid info.txt: missing book authors") + } + + if len(info.Narrators) == 0 { + return nil, fmt.Errorf("invalid info.txt: missing book narrators") + } + + if len(info.URL) == 0 { + return nil, fmt.Errorf("invalid info.txt: missing book URL") + } + + if err := s.Err(); err != nil { + return nil, err + } + + return info, nil +} + +func ListDownloadedBooks(dir string) ([]*audible.Book, error) { booksByID := make(map[string]*audible.Book) books := make([]*audible.Book, 0) err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { @@ -98,41 +149,56 @@ func ListDownloadedBooks(dir string) ([]*audible.Book, error) { if info.IsDir() { return nil } - isMP4 := filepath.Ext(path) == ".mp4" + + // identify existing books by their info.txt files + if filepath.Base(path) != "info.txt" { + return nil + } + file, err := os.Open(path) if err != nil { - if isMP4 { - log.Warnf("Unable to read %s: %s", path, err) - } - return nil + log.Warnf("Unable to read %s: %s", path, err) + return err } defer file.Close() - if _, _, err := tag.Identify(file); err != nil { - if isMP4 { - log.Warnf("Unable to identify %s: %s", path, err) - } - return nil + bookInfo, err := parseInfoTxt(file) + if err != nil { + log.Warnf("Unable to parse book info (%s): %s", path, err) + return err + } + + b := &audible.Book{ + Title: bookInfo.Title, + Authors: bookInfo.Authors, + Narrators: bookInfo.Narrators, + AudibleURL: bookInfo.URL, } - meta, err := tag.ReadAtoms(file) + + isMP4 := false + exts := []string{".mp4", ".aax"} + m, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*")) if err != nil { - if isMP4 { - log.Warnf("Unable to read tag data %s: %s", path, err) + log.Debugf("unable to glob %s: %v", path, err) + return err + } + for _, e := range m { + for _, ext := range exts { + if strings.Contains(e, ext) { + if filepath.Ext(e) == ".mp4" { + isMP4 = true + } + e = strings.TrimSuffix(e, ".icloud") + b.LocalPath = e + log.Debugf("setting local path for book(%s): %s", b.ID(), e) + } } - return nil } - if strings.ToLower(meta.Genre()) != "audiobook" { + + if b.LocalPath == "" { + log.Warnf("unable to find audio file for %s", path) return nil } - var thumbImg image.Image - if p := meta.Picture(); p != nil { - thumbImg, _, _ = image.Decode(bytes.NewReader(p.Data)) - } - b := &audible.Book{ - Title: meta.Title(), - Authors: parseAuthors(meta.Artist()), - ThumbImage: thumbImg, - LocalPath: path, - } + if eb := booksByID[b.ID()]; eb != nil { // don't overwrite an mp4 entry (e.g. with an aax one) if filepath.Ext(eb.LocalPath) == ".mp4" { @@ -141,6 +207,41 @@ func ListDownloadedBooks(dir string) ([]*audible.Book, error) { } books = append(books, b) booksByID[b.ID()] = b + + if !isMP4 { + return nil + } + + mp4File, err := os.Open(b.LocalPath) + if err != nil { + log.Warnf("Unable to read %s: %s", path, err) + return nil + } + defer mp4File.Close() + if _, _, err := tag.Identify(mp4File); err != nil { + log.Warnf("Unable to identify %s: %s", path, err) + return nil + } + + meta, err := tag.ReadAtoms(mp4File) + if err != nil { + log.Warnf("Unable to read tag data %s: %s", path, err) + return nil + } + if strings.ToLower(meta.Genre()) != "audiobook" { + return nil + } + + if title := meta.Title(); title != "" { + b.Title = title + } + + var thumbImg image.Image + if p := meta.Picture(); p != nil { + thumbImg, _, _ = image.Decode(bytes.NewReader(p.Data)) + } + b.ThumbImage = thumbImg + return nil }) if err != nil {