ulthar.xyz > Repos

audible-dl

An archiving tool for Audible audiobook libraries
About Files Commits git clone https://ulthar.xyz/repos/audible-dl/audible-dl.git

audible-dl/client.go

Download raw file: client.go

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"gopkg.in/yaml.v2"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"sync"
)

// Escape code clear a line and move the cursor to the beginning
const clearline string = "\x1b[2k\r"

// Return S as bold text
func bold(s string) string {
	return "\033[1m" + s + "\033[m"
}

////////////////////////////////////////////////////////////////////////
//       _ _            _           _     _           _
//   ___| (_) ___ _ __ | |_    ___ | |__ (_) ___  ___| |_
//  / __| | |/ _ \ '_ \| __|  / _ \| '_ \| |/ _ \/ __| __|
// | (__| | |  __/ | | | |_  | (_) | |_) | |  __/ (__| |_
//  \___|_|_|\___|_| |_|\__|  \___/|_.__// |\___|\___|\__|
//                                     |__/
////////////////////////////////////////////////////////////////////////

// A single instance of the client struct is used to encapsulate all
// internal state.  SaveDir is where we're saving completed .m4b
// files, TempDir is where we're downloading .aax files to, and
// DataDir is where we look for cache and authentication files.
// Accounts is a slice of the accounts set up in the config file and
// Downloaded is map of all the books we've previously downloaded.
// This map is populated from a cache file which exists to allow the
// user to rename and organize their collection after they've been
// downloaded.
type Client struct {
	SaveDir    string
	TempDir    string
	DataDir    string
	Accounts   []Account
	Downloaded map[string]Book
}

// Return a Client struct partially populated from the .yml file
// passed in CFGFILE.
func MakeClient(cfgfile, tempdir, savedir, datadir string) Client {
	var client Client
	client.Downloaded = make(map[string]Book)
	raw, err := os.ReadFile(cfgfile)
	expect(err, "Please create the config file with at least one account")
	expect(yaml.Unmarshal(raw, &client), "Bad yaml in config file")
	client.TempDir = tempdir
	client.DataDir = datadir
	if os.Getenv("AUDIBLE_DL_ROOT") != "" {
		client.SaveDir = savedir
	}
	os.MkdirAll(tempdir, 0755)
	os.MkdirAll(savedir, 0755)
	return client
}

// Make sure everything in the client is set up correctly.
func (c *Client) Validate() {
	if c.SaveDir == "" {
		log.Fatal("savdir not specified on config file")
	}
	if c.TempDir == "" {
		panic("Failed to infer TempDir!")
	}
	if len(c.Accounts) == 0 {
		log.Fatal("Couldn't find any accounts in config file.")
	}
	for _, a := range c.Accounts {
		if a.Name == "" {
			log.Fatal("Account name not specified in config file.")
		}
		if a.Bytes == "" {
			log.Fatal("Activation bytes not present for account " +
				a.Name)
		}
		// It's okay not to have cookies
	}
}

// Given an account name (likely passed with -a on the command line),
// return a pointer to the corresponding Account struct.
func (c *Client) FindAccount(name string) *Account {
	for _, a := range c.Accounts {
		if a.Name == name {
			return &a
		}
	}
	return nil
}

// Given an account name (likely passed with -a on the command line),
// make sure it exists.  If an empty string is passed and there are
// more than one accounts set up or if the requested account doesn't
// exist, throw an error.
func (c *Client) NeedAccount(account string) (string, error) {
	if len(c.Accounts) != 1 && account == "" {
		return "", errors.New(
			"You have multiple accounts set up, please specify one")
	} else if account == "" {
		return c.Accounts[0].Name, nil
	} else if len(c.Accounts) > 1 && account != "" {
		if c.FindAccount(account) == nil {
			return "", errors.New(
				"Account " + account + " doesn't exist")
		}
	}
	return account, nil
}

// Given a .har file containing a full archive of a GET request to
// audible.com/library/titles in HARPATH, import the cookies therein
// into ACCOUNT's cookie store.
func (c *Client) ImportCookies(account, harpath string) {
	account, err := c.NeedAccount(account)
	authpath := c.DataDir + account + ".cookies.json"
	unwrap(err)
	a := c.FindAccount(account)
	raw, err := ioutil.ReadFile(harpath)
	unwrap(err)
	a.ImportCookiesFromHAR(raw)
	json, _ := json.MarshalIndent(a.Auth, "", "  ")
	unwrap(ioutil.WriteFile(authpath, json, 0644))
	fmt.Printf("Imported cookies from %s into %s\n", harpath, authpath)
}

// Using ACCOUNT's bytes, convert a single .aax file passed in AAXPATH
// and return the path of the created .m4b file.
func (c *Client) ConvertSingleBook(account string, aaxpath string) string {
	account, err := c.NeedAccount(account)
	unwrap(err)
	a := c.FindAccount(account)
	var m4bpath string
	if aaxpath[len(aaxpath)-4:] == ".aax" {
		m4bpath = aaxpath[:len(aaxpath)-4] + ".m4b"
	} else {
		m4bpath = aaxpath + ".m4b"
	}
	err, ffmpegstderr := a.Convert(aaxpath, m4bpath, c)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s\n", ffmpegstderr)
		log.Fatalf("Failed to convert %s with bytes %s\n",
			filepath.Base(aaxpath), a.Bytes)
	}
	return m4bpath
}

// For each account, load the cached cookies into memory.
func (c *Client) GetCookies() {
	for i := 0; i < len(c.Accounts); i++ {
		a := &c.Accounts[i]
		if !a.Scrape {
			continue
		}
		path := c.DataDir + a.Name + ".cookies.json"
		raw, err := os.ReadFile(path)
		expect(err, "Couldn't find any cookies for account "+a.Name)
		expect(json.Unmarshal(raw, &a.Auth),
			"Unknown json in cookie file for account "+a.Name)
	}
}

// Populate client's hash table of previously downloaded books from a
// json file.
func (c *Client) GetDownloaded() {
	var books []Book
	path := c.DataDir + "downloaded_books.json"
	raw, err := os.ReadFile(path)
	if err != nil {
		// It's okay for the file not to exist
		if !os.IsNotExist(err) {
			log.Fatal(err)
		}
		return
	}
	expect(json.Unmarshal(raw, &books), "Bad json in downloaded book file")
	for _, b := range books {
		c.Downloaded[b.Title] = b
	}
}

// Write the map of downloaded books off to the file, overwriting its
// old contents
func (c *Client) SetDownloaded() {
	var books []Book
	for _, b := range c.Downloaded {
		books = append(books, b)
	}
	json, _ := json.MarshalIndent(books, "", "  ")
	unwrap(ioutil.WriteFile(c.DataDir+"downloaded_books.json", json, 0644))
}

// This function orchestrates the scraping, downloading, and
// conversion of audiobooks for all configured acounts or the one
// passed in ACCOUNT.  It also displays a progress report in stdout.
func (c *Client) ScrapeLibrary(account string) {
	var toscrape []Account
	if a := c.FindAccount(account); a != nil {
		toscrape = append(toscrape, *a)
		if !a.Scrape {
			log.Fatalf("Account %s has `scrape' set to false.",
				a.Name)
		}
	} else {
		toscrape = c.Accounts
	}
	for _, a := range toscrape {
		if !a.Scrape {
			continue
		}
		for i := 0; i < len(books); i++ {
			b := books[i]
			if _, ok := c.Downloaded[b.Title]; ok {
				continue
			}
			fmt.Printf("\033[1mDownloading Book\033[m %s...", b.Title)
			aax := a.DownloadSingleBook(c, b)
			fmt.Printf("done\n")
			fmt.Printf("\033[1mConverting Book\033[m %s...", b.Title)
			m4b := c.ConvertSingleBook(a.Name, aax)
			unwrap(os.Rename(m4b, c.SaveDir+b.FileName+".m4b"))
			fmt.Printf("done\n")
			c.Downloaded[b.Title] = b
			c.SetDownloaded()
		}
	}
}

func scrapeLibraryWithPrinting(a *Account) []Book {
	var wg sync.WaitGroup
	ch := make(chan int)
	wg.Add(1)
	go func() {
		defer wg.Done()
		var npages int
		for i := range ch {
			npages = i
			fmt.Printf("%s%s %d", clearline, bold("Scraping Page"),
				npages)
		}
		fmt.Printf("%s%s %d/%d\n", clearline, bold("Scraped Page"),
			npages, npages)
	}()
	books, err := a.ScrapeFullLibrary(ch)
	wg.Wait()
	if err != nil {
		fmt.Fprintf(os.Stderr, "BEGIN SCRAPER LOG\n")
		a.PrintScraperDebuggingInfo()
		fmt.Fprintf(os.Stderr, "END SCRAPER LOG\n")
		log.Println(err)
		fmt.Fprintf(os.Stderr, debugScraperMessage, a.Name, a.Name)
	}
	return books
}
Generated 2024-06-10 19:24:15 -0700 by RepoRat