audible-dl
An archiving tool for Audible audiobook libraries
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
}