From f570971f6ea503fce036b71f03fd466d1afaf268 Mon Sep 17 00:00:00 2001 From: ale <ale@incal.net> Date: Mon, 10 Nov 2014 11:02:56 +0000 Subject: [PATCH] support multiple files per ebook --- cmd/liber/liber.go | 29 +++++--- database.go | 69 +++++++++++++++--- database_test.go | 78 ++++++++++++++++++--- files.go | 11 +++ htdocs/templates/book.html | 6 +- metadata.go | 13 ++-- sync.go | 99 ++++++++++++++++---------- sync_test.go | 16 ++--- update.go | 140 +++++++++++++++++++++++-------------- web.go | 31 ++++++-- web_test.go | 94 +++++++++++++++++++++++++ 11 files changed, 443 insertions(+), 143 deletions(-) create mode 100644 web_test.go diff --git a/cmd/liber/liber.go b/cmd/liber/liber.go index c25ef76..e578ae8 100644 --- a/cmd/liber/liber.go +++ b/cmd/liber/liber.go @@ -19,12 +19,13 @@ import ( ) var ( - databaseDir = flag.String("db-dir", "~/.liber", "database directory") - bookDir = flag.String("book-dir", "", "books directory") - update = flag.Bool("update", false, "update the db") - search = flag.Bool("search", false, "search something") - remotesync = flag.String("sync", "", "push data to remote server") - httpserver = flag.String("http-server", "", "start the HTTP server on the specified address") + databaseDir = flag.String("db-dir", "~/.liber", "database directory") + bookDir = flag.String("book-dir", "", "books directory") + update = flag.Bool("update", false, "update the db") + search = flag.Bool("search", false, "search something") + remotesync = flag.String("sync", "", "push data to remote server") + httpserver = flag.String("http-server", "", "start the HTTP server on the specified address") + noninteractive = flag.Bool("noninteractive", false, "disable interactive metadata prompts on update") ) // Various ways to ask a user to choose something. @@ -127,6 +128,10 @@ func promptUserDialog(path string, choices []*liber.Metadata) (*liber.Metadata, var promptMutex sync.Mutex func promptUser(path string, choices []*liber.Metadata) *liber.Metadata { + if *noninteractive { + return nil + } + promptMutex.Lock() defer promptMutex.Unlock() @@ -160,9 +165,14 @@ func doSearch(db *liber.Database, query string) { fmt.Printf("No results.\n") } else { fmt.Printf("%d results found:\n\n", results.NumResults) - for i, r := range results.Results { - fmt.Printf("%d) %s\n", i+1, r.Metadata.String()) - fmt.Printf(" %s\n", r.Path) + for i, book := range results.Results { + fmt.Printf("%d) %s\n", i+1, book.Metadata.String()) + if files, err := db.GetBookFiles(book.Id); err == nil { + for _, f := range files { + fmt.Printf(" %s: %s\n", strings.TrimPrefix(f.FileType, "."), f.Path) + } + } + fmt.Printf("\n") } } } @@ -213,6 +223,7 @@ func main() { if err == nil { defer logf.Close() log.SetOutput(logf) + log.SetFlags(log.Ldate | log.Ltime) } doUpdate(db, expandTilde(*bookDir)) diff --git a/database.go b/database.go index fb88395..070200b 100644 --- a/database.go +++ b/database.go @@ -17,8 +17,9 @@ import ( ) var ( - BookBucket = []byte("ebook") - FileBucket = []byte("file") + BookBucket = []byte("ebook") + FileBucket = []byte("file") + BookFileBucket = []byte("ebook_file") keySeparator = byte('/') ) @@ -84,10 +85,9 @@ func defaultIndexMapping() *bleve.IndexMapping { } type Book struct { - Id BookId - Path string + Id BookId + // Path string CoverPath string - FileType string Metadata *Metadata } @@ -96,11 +96,12 @@ func (b *Book) Type() string { } type File struct { - Path string - Mtime time.Time - Size int64 - Error bool - Id BookId + Path string + FileType string + Mtime time.Time + Size int64 + Error bool + Id BookId } func (f *File) HasChanged(info os.FileInfo) bool { @@ -135,6 +136,7 @@ func NewDb(path string) (*Database, error) { } func (db *Database) setupLevelDb(path string) error { + // Use 256MB of cache and a small Bloom filter. opts := levigo.NewOptions() db.leveldbCache = levigo.NewLRUCache(2 << 28) opts.SetCache(db.leveldbCache) @@ -185,6 +187,24 @@ func (db *Database) GetFile(path string) (*File, error) { return &f, nil } +func (db *Database) GetBookFiles(bookid BookId) ([]*File, error) { + ro := levigo.NewReadOptions() + defer ro.Close() + it := db.leveldb.NewIterator(ro) + defer it.Close() + start, end := keyRange(bktToKey(BookFileBucket, bookid.Key())) + var out []*File + for it.Seek(start); it.Valid() && bytes.Compare(it.Key(), end) < 0; it.Next() { + var filepath string + if json.Unmarshal(it.Value(), &filepath) == nil { + if file, err := db.GetFile(filepath); err == nil { + out = append(out, file) + } + } + } + return out, nil +} + func (db *Database) Get(bucket, key []byte, obj interface{}) error { ro := levigo.NewReadOptions() defer ro.Close() @@ -202,8 +222,18 @@ func (db *Database) PutBook(b *Book) error { return db.index.Index(b.Id.String(), b.Metadata) } +func fileBookKey(path string, bookid BookId) []byte { + return bytes.Join([][]byte{bookid.Key(), []byte(path)}, []byte{keySeparator}) +} + func (db *Database) PutFile(f *File) error { - return db.Put(FileBucket, []byte(f.Path), f) + if err := db.Put(FileBucket, []byte(f.Path), f); err != nil { + return err + } + if !f.Error { + return db.Put(BookFileBucket, fileBookKey(f.Path, f.Id), f.Path) + } + return nil } func (db *Database) Put(bucket, key []byte, obj interface{}) error { @@ -223,6 +253,23 @@ func (db *Database) DeleteBook(bookid BookId) error { return db.index.Delete(bookid.String()) } +func (db *Database) DeleteFile(path string) error { + f, err := db.GetFile(path) + if err != nil { + return nil + } + + db.Delete(FileBucket, []byte(path)) + db.Delete(BookFileBucket, fileBookKey(path, f.Id)) + + // Delete the book if there are no files left. + if files, err := db.GetBookFiles(f.Id); err == nil && len(files) == 0 { + db.DeleteBook(f.Id) + } + + return nil +} + func (db *Database) Delete(bucket, key []byte) error { wo := levigo.NewWriteOptions() defer wo.Close() diff --git a/database_test.go b/database_test.go index 6d6a2c4..660ba83 100644 --- a/database_test.go +++ b/database_test.go @@ -1,16 +1,16 @@ package liber import ( + "io" "io/ioutil" "os" "testing" ) -var testdbdir = "/tmp/liber-test-database" - type testDatabase struct { - db *Database - path string + db *Database + path string + refbookid BookId } func (td *testDatabase) Close() { @@ -29,15 +29,28 @@ func newTestDatabase(t *testing.T) (*testDatabase, *Database) { if err = db.PutBook(book); err != nil { t.Fatalf("PutBook(): %v", err) } + if err = db.PutFile(testEpubFile(path, book.Id)); err != nil { + t.Fatalf("PutFile(): %v", err) + } - return &testDatabase{db: db, path: path}, db + return &testDatabase{db: db, path: path, refbookid: book.Id}, db +} + +func testEpubFile(dir string, bookid BookId) *File { + f, _ := ioutil.TempFile(dir, "ebook-") + io.WriteString(f, "epub\n") + f.Close() + return &File{ + Id: bookid, + Path: f.Name(), + FileType: ".epub", + Size: 4, + } } func testEbook() *Book { return &Book{ - Id: NewID(), - Path: "/path/to/ebook", - FileType: ".epub", + Id: NewID(), Metadata: &Metadata{ Title: "20,000 Leagues under the sea", Creator: []string{"Jules Verne"}, @@ -46,6 +59,55 @@ func testEbook() *Book { } } +func TestDatabase_Get(t *testing.T) { + td, db := newTestDatabase(t) + defer td.Close() + + _, err := db.GetBook(td.refbookid) + if err != nil { + t.Fatalf("GetBook(%d): %v", td.refbookid, err) + } + files, err := db.GetBookFiles(td.refbookid) + if err != nil { + t.Fatalf("GetBookFiles(%d): %v", td.refbookid, err) + } + if len(files) != 1 { + t.Fatalf("GetBookFiles(%d) bad result: %v", td.refbookid, files) + } +} + +func TestDatabase_BookFileRelation(t *testing.T) { + td, db := newTestDatabase(t) + defer td.Close() + + checkFiles := func(tag string, n int) []*File { + files, err := db.GetBookFiles(td.refbookid) + if err != nil { + t.Fatalf("GetBookFiles@%s(%d): %v", tag, td.refbookid, err) + } + if len(files) != n { + t.Fatalf("GetBookFiles@%s(%d) bad result (exp. len=%d): %v", tag, td.refbookid, n, files) + } + return files + } + + files := checkFiles("init", 1) + file0 := files[0] + file1 := testEpubFile(td.path, td.refbookid) + db.PutFile(file1) + checkFiles("post_add", 2) + + db.DeleteFile(file1.Path) + checkFiles("post_delete", 1) + + db.DeleteFile(file0.Path) + checkFiles("post_delete_2", 0) + + if _, err := db.GetBook(td.refbookid); err == nil { + t.Fatal("Book was not removed when n.files==0") + } +} + func TestDatabase_Search(t *testing.T) { td, db := newTestDatabase(t) defer td.Close() diff --git a/files.go b/files.go index 2f399f2..4cfae94 100644 --- a/files.go +++ b/files.go @@ -48,3 +48,14 @@ func (s *FileStorage) Open(path string) (*os.File, error) { } return os.Open(filepath.Join(s.Root, path)) } + +// Rename oldpath to newpath. +func (s *FileStorage) Rename(oldpath, newpath string) error { + if !strings.HasPrefix(oldpath, "/") { + oldpath = filepath.Join(s.Root, oldpath) + } + if !strings.HasPrefix(newpath, "/") { + newpath = filepath.Join(s.Root, newpath) + } + return os.Rename(oldpath, newpath) +} diff --git a/htdocs/templates/book.html b/htdocs/templates/book.html index 0d8ff7c..505b104 100644 --- a/htdocs/templates/book.html +++ b/htdocs/templates/book.html @@ -32,12 +32,14 @@ <p>{{.Book.Metadata.Description}}</p> {{end}} + {{range $i, $f := .Files}} <p> - <a class="btn btn-large btn-primary" href="/dl/{{.Book.Id}}"> + <a class="btn btn-large btn-primary" href="/dl/{{$f.Id}}/{{$i}}"> <span class="glyphicon glyphicon-download-alt"></span> - Download + Download ({{$f.FileType}}) </a> </p> + {{end}} </div> diff --git a/metadata.go b/metadata.go index d762b6e..e6456d9 100644 --- a/metadata.go +++ b/metadata.go @@ -244,7 +244,7 @@ func parseAnything(filename string) (*Metadata, error) { }, nil } -func Parse(filename string) (*Book, error) { +func Parse(filename string) (*Book, string, error) { var m *Metadata var err error ext := strings.ToLower(filepath.Ext(filename)) @@ -256,16 +256,11 @@ func Parse(filename string) (*Book, error) { case ".pdf": m, err = parseAnything(filename) default: - return nil, errors.New("unsupported file format") + return nil, "", errors.New("unsupported file format") } if err != nil { - return nil, err + return nil, "", err } - b := &Book{ - Path: filename, - FileType: ext, - Metadata: m, - } - return b, nil + return &Book{Metadata: m}, ext, nil } diff --git a/sync.go b/sync.go index e59eae6..7b4ce84 100644 --- a/sync.go +++ b/sync.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sync" + "time" ) const ( @@ -20,7 +21,7 @@ const ( type SyncClient interface { DiffRequest(*diffRequest) (*diffResponse, error) - SendBook(*Book) error + SendBook(*Book, []*File) error } type remoteServer struct { @@ -64,14 +65,14 @@ func (r *remoteServer) DiffRequest(diffreq *diffRequest) (*diffResponse, error) return &diffresp, nil } -func addFilePart(w *multipart.Writer, varname, filename string) error { +func addFilePart(w *multipart.Writer, varname, filename, mimeFilename string) error { file, err := os.Open(filename) if err != nil { return err } defer file.Close() - part, err := w.CreateFormFile(varname, filepath.Base(filename)) + part, err := w.CreateFormFile(varname, mimeFilename) if err != nil { return err } @@ -83,25 +84,29 @@ func addFilePart(w *multipart.Writer, varname, filename string) error { } // SendBook uploads a book to the remote server. -func (r *remoteServer) SendBook(book *Book) error { +func (r *remoteServer) SendBook(book *Book, files []*File) error { // Create a multipart request with the JSON-encoded metadata // and the actual file contents as two separate mime/multipart // sections. var body bytes.Buffer w := multipart.NewWriter(&body) - w.WriteField("type", book.FileType) + part, err := w.CreateFormFile("meta", "meta.json") if err := json.NewEncoder(part).Encode(book.Metadata); err != nil { return err } - if err := addFilePart(w, "book", book.Path); err != nil { - w.Close() - return err + for i, f := range files { + varname := fmt.Sprintf("book%d", i) + filename := fmt.Sprintf("%d%s", book.Id, f.FileType) + if err := addFilePart(w, varname, f.Path, filename); err != nil { + w.Close() + return err + } } if book.CoverPath != "" { - if err := addFilePart(w, "cover", book.CoverPath); err != nil { + if err := addFilePart(w, "cover", book.CoverPath, "cover.jpg"); err != nil { w.Close() return err } @@ -195,9 +200,12 @@ func (db *Database) Sync(remote SyncClient) error { wg.Add(1) go func() { for id := range ch { - if book, err := db.GetBook(ParseID(id)); err == nil { - if err := remote.SendBook(book); err != nil { - log.Printf("SendBook(%d): %v", id, err) + bookid := ParseID(id) + if book, err := db.GetBook(bookid); err == nil { + if files, err := db.GetBookFiles(bookid); err == nil { + if err := remote.SendBook(book, files); err != nil { + log.Printf("SendBook(%d): %v", id, err) + } } } } @@ -251,13 +259,6 @@ func (l *syncServer) handleDiffRequest(w http.ResponseWriter, req *http.Request) } func (l *syncServer) handleSyncUpload(w http.ResponseWriter, req *http.Request) { - filetype := req.FormValue("type") - if filetype == "" { - log.Printf("request with no 'type' field") - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - mf, _, err := req.FormFile("meta") if err != nil { log.Printf("request with no 'meta' field") @@ -284,21 +285,46 @@ func (l *syncServer) handleSyncUpload(w http.ResponseWriter, req *http.Request) book := &Book{ Id: bookid, Metadata: &md, - FileType: filetype, - Path: l.storage.Path(fmt.Sprintf("%d%s", bookid, filetype)), } - // Store the data into a custom path and save the book into - // the local database. - if err := savePart(req, "book", l.storage, book.Path); err != nil { - log.Printf("error saving local file: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + // Save the file data to our local storage. + for i := 0; i < 10; i++ { + // Use a temporary file, we'll know the right + // extension to use only after having parsed the + // file's MIME header. + tmppath := l.storage.Path(fmt.Sprintf("%d.%d.tmp", bookid, i)) + varname := fmt.Sprintf("book%d", i) + size, hdr, err := savePart(req, varname, l.storage, tmppath) + if err == http.ErrMissingFile { + break + } else if err != nil { + log.Printf("error saving local file: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + filetype := filepath.Ext(hdr.Filename) + path := l.storage.Path(fmt.Sprintf("%d.%d%s", bookid, i, filetype)) + if err := l.storage.Rename(tmppath, path); err != nil { + log.Printf("error moving local file: %v", err) + } + + file := &File{ + Path: path, + FileType: filetype, + Mtime: time.Now(), + Size: size, + Id: bookid, + } + if err := l.db.PutFile(file); err != nil { + log.Printf("error saving file to the database: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } // If the request contains a cover image, save that as well. - coverPath := l.storage.Path(fmt.Sprintf("%s%s.cover.png", book.Id, filetype)) - if err := savePart(req, "cover", l.storage, coverPath); err == nil { + coverPath := l.storage.Path(fmt.Sprintf("%s.cover.png", book.Id)) + if _, _, err := savePart(req, "cover", l.storage, coverPath); err == nil { book.CoverPath = coverPath } @@ -312,21 +338,22 @@ func (l *syncServer) handleSyncUpload(w http.ResponseWriter, req *http.Request) w.WriteHeader(200) } -func savePart(req *http.Request, fieldname string, storage *FileStorage, outname string) error { - f, _, err := req.FormFile(fieldname) +func savePart(req *http.Request, fieldname string, storage *FileStorage, outname string) (int64, *multipart.FileHeader, error) { + f, hdr, err := req.FormFile(fieldname) if err != nil { - return err + return 0, nil, err } outf, err := storage.Create(outname) if err != nil { - return err + return 0, nil, err } defer outf.Close() - if _, err := io.Copy(outf, f); err != nil { - return err + n, err := io.Copy(outf, f) + if err != nil { + return 0, nil, err } - return nil + return n, hdr, nil } diff --git a/sync_test.go b/sync_test.go index f0efe27..82e66ba 100644 --- a/sync_test.go +++ b/sync_test.go @@ -2,7 +2,6 @@ package liber import ( "fmt" - "io" "io/ioutil" "net/http" "net/http/httptest" @@ -11,7 +10,7 @@ import ( "testing" ) -func newTestHttpServer(db *Database, updir string) *httptest.Server { +func newTestSyncHttpServer(db *Database, updir string) *httptest.Server { localsrv := &syncServer{db, &FileStorage{Root: updir, Nesting: 2}} mux := http.NewServeMux() @@ -22,11 +21,6 @@ func newTestHttpServer(db *Database, updir string) *httptest.Server { } func TestSync_Sync(t *testing.T) { - // Actually create a file to upload, or the sync will fail. - f, _ := ioutil.TempFile("", "ebook-") - io.WriteString(f, "foo\n") - f.Close() - // Create a temporary directory to store uploads. updir, _ := ioutil.TempDir("", "ebook-upload-") defer os.RemoveAll(updir) @@ -38,20 +32,20 @@ func TestSync_Sync(t *testing.T) { defer td2.Close() for i := 0; i < 10; i++ { + bookid := NewID() db.PutBook(&Book{ - Id: NewID(), - Path: f.Name(), - FileType: ".epub", + Id: bookid, Metadata: &Metadata{ Title: fmt.Sprintf("Book #%d", i+1), Creator: []string{"Random Author"}, ISBN: []string{strconv.Itoa(i + 1)}, }, }) + db.PutFile(testEpubFile(updir, bookid)) } // Run a sync from db to db2. - srv := newTestHttpServer(db2, updir) + srv := newTestSyncHttpServer(db2, updir) defer srv.Close() cl := NewRemoteServer(srv.URL) diff --git a/update.go b/update.go index b97c2bd..8068809 100644 --- a/update.go +++ b/update.go @@ -17,22 +17,29 @@ const ( type MetadataChooserFunc func(string, []*Metadata) *Metadata type fileData struct { - source int - path string - id BookId - info os.FileInfo + source int + path string + filetype string + id BookId + info os.FileInfo } func (f fileData) toLiberFile(haserr bool) *File { return &File{ - Path: f.path, - Mtime: f.info.ModTime(), - Size: f.info.Size(), - Id: f.id, - Error: haserr, + Path: f.path, + FileType: f.filetype, + Mtime: f.info.ModTime(), + Size: f.info.Size(), + Id: f.id, + Error: haserr, } } +type fileAndBook struct { + f fileData + b *Book +} + func dbFileScanner(db *Database, fileCh chan fileData) { for iter := db.Scan(FileBucket); iter.Valid(); iter.Next() { var f File @@ -83,34 +90,28 @@ func differ(db *Database, basedir string) chan fileData { close(fileCh) }() go func() { - // Detect files that have not changed, i.e. appear in - // the database and the filesystem. Keep track of book - // IDs so that once all entries have been processed we - // can delete those books from the database where the - // original file has been removed. + // Merge the two sources and keep track of files that + // only appear in the database but not on the + // filesystem, so we can remove them at the end. + // All entries with source == SourceFS will be sent to + // the output channel in any case. allSources := SourceDB | SourceFS tmp := make(map[string]int) - ids := make(map[string]BookId) for f := range fileCh { - // log.Printf("differ: %#v", f) tmp[f.path] |= f.source // Delete entries as soon as we've seen them - // from both sources. + // originate from both sources. if tmp[f.path] == allSources { - // log.Printf("differ: dropping %s", f.path) delete(tmp, f.path) - delete(ids, f.path) } if f.source == SourceFS { outCh <- f - } else { - ids[f.path] = f.id } } for path, value := range tmp { if value == SourceDB { - log.Printf("removing book %s", path) - db.DeleteBook(ids[path]) + log.Printf("removing file %s", path) + db.DeleteFile(path) } } close(outCh) @@ -118,41 +119,35 @@ func differ(db *Database, basedir string) chan fileData { return outCh } -func adder(db *Database, chooser MetadataChooserFunc, fileCh chan fileData) { +func extractor(db *Database, chooser MetadataChooserFunc, fileCh chan fileData, outCh chan fileAndBook) { for f := range fileCh { - var oldid BookId if oldfile, err := db.GetFile(f.path); err == nil { if !oldfile.HasChanged(f.info) { continue } - oldid = oldfile.Id + f.id = oldfile.Id } - - var err error - f.id, err = importBook(db, f, oldid, chooser) - if err != nil { - log.Printf("Could not add %s: %v", f.path, err) + book, filetype, err := parseMeta(f, chooser) + if err == nil { + f.filetype = filetype + outCh <- fileAndBook{f: f, b: book} continue } - file := f.toLiberFile(err != nil) + // Parse errors are permanent. + log.Printf("Could not parse %s: %v", f.path, err) + file := f.toLiberFile(true) if err := db.PutFile(file); err != nil { - log.Println(err) + log.Printf("Error saving file %s to db: %v", file.Path, err) } } } -func importBook(db *Database, f fileData, oldid BookId, chooser MetadataChooserFunc) (BookId, error) { +func parseMeta(f fileData, chooser MetadataChooserFunc) (*Book, string, error) { // Attempt direct metadata extraction. - book, err := Parse(f.path) + book, filetype, err := Parse(f.path) if err != nil { - return 0, err - } - - if oldid != 0 { - book.Id = oldid - } else { - book.Id = NewID() + return nil, "", err } // Check if a Calibre OPF file exists. @@ -175,9 +170,10 @@ func importBook(db *Database, f fileData, oldid BookId, chooser MetadataChooserF } } - // Check if the book metadata looks ok. + // Check if the book metadata looks ok. If not, don't even + // bother looking for a cover image. if !book.Metadata.Sufficient() { - return 0, errors.New("insufficient metadata") + return nil, "", errors.New("insufficient metadata") } // Try to find a cover image. Look on the local filesystem @@ -186,7 +182,7 @@ func importBook(db *Database, f fileData, oldid BookId, chooser MetadataChooserF if _, err := os.Stat(localCoverPath); err == nil { book.CoverPath = localCoverPath } else if imageData, err := GetGoogleBooksCover(book.Metadata); err == nil { - imageFileName := book.Path + ".cover.png" + imageFileName := f.path + ".cover.png" if imgf, err := os.Create(imageFileName); err != nil { log.Printf("Could not save cover image for %d: %v", book.Id, err) } else { @@ -196,23 +192,63 @@ func importBook(db *Database, f fileData, oldid BookId, chooser MetadataChooserF } } - // Save the book in our database. - if err := db.PutBook(book); err != nil { - return book.Id, err + return book, filetype, nil +} + +func dbwriter(db *Database, ch chan fileAndBook) { + for pair := range ch { + saveBook := true + + // If this is a new file, see if it matches an already + // existing book. + if pair.f.id == 0 { + log.Printf("potential new book: %#v", pair.b.Metadata) + if match, err := db.Find(pair.b.Metadata); err == nil { + log.Printf("%s matches existing book %d", pair.f.path, match.Id) + // Ignore new metadata. + pair.b = match + saveBook = false + } else { + // Assign a new ID to the book. + pair.b.Id = NewID() + } + pair.f.id = pair.b.Id + } else { + // Overwrite the old book metadata. + pair.b.Id = pair.f.id + } + + if saveBook { + if err := db.PutBook(pair.b); err != nil { + log.Printf("Error saving book %d to db: %v", pair.b.Id, err) + continue + } + log.Printf("%s -> %d", pair.f.path, pair.b.Id) + } + + file := pair.f.toLiberFile(false) + if err := db.PutFile(file); err != nil { + log.Printf("Error saving file %s to db: %v", file.Path, err) + } } - log.Printf("%s -> %d", f.path, book.Id) - return book.Id, nil } func (db *Database) Update(dir string, chooser MetadataChooserFunc) { + // Parallelize metadata extraction, serialize database updates + // (so that index-based de-duplication works). var wg sync.WaitGroup ch := differ(db, dir) + pch := make(chan fileAndBook) for i := 0; i < 10; i++ { wg.Add(1) go func() { - adder(db, chooser, ch) + extractor(db, chooser, ch, pch) wg.Done() }() } - wg.Wait() + go func() { + wg.Wait() + close(pch) + }() + dbwriter(db, pch) } diff --git a/web.go b/web.go index 3a173c9..0e6bdd0 100644 --- a/web.go +++ b/web.go @@ -142,10 +142,18 @@ func (s *uiServer) withBook(f func(*Book, http.ResponseWriter, *http.Request)) h } func (s *uiServer) handleShowBook(book *Book, w http.ResponseWriter, req *http.Request) { + files, err := s.db.GetBookFiles(book.Id) + if err != nil { + log.Printf("ShowBook(%d): %v", book.Id, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + ctx := struct { Query string Book *Book - }{Book: book} + Files []*File + }{Book: book, Files: files} render("book.html", w, &ctx) } @@ -156,7 +164,20 @@ var contentTypeMap = map[string]string{ } func (s *uiServer) handleDownloadBook(book *Book, w http.ResponseWriter, req *http.Request) { - f, err := s.storage.Open(book.Path) + idx, _ := strconv.Atoi(mux.Vars(req)["n"]) + + files, err := s.db.GetBookFiles(book.Id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if idx < 0 || idx >= len(files) { + http.NotFound(w, req) + return + } + file := files[idx] + + f, err := s.storage.Open(file.Path) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -164,8 +185,8 @@ func (s *uiServer) handleDownloadBook(book *Book, w http.ResponseWriter, req *ht defer f.Close() w.Header().Set("ETag", book.Id.String()) - w.Header().Set("Content-Type", contentTypeMap[book.FileType]) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%d%s\"", book.Id, book.FileType)) + w.Header().Set("Content-Type", contentTypeMap[file.FileType]) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%d%s\"", book.Id, file.FileType)) http.ServeContent(w, req, "", time.Time{}, f) } @@ -261,7 +282,7 @@ func NewHttpServer(db *Database, storage, cache *FileStorage, addr string) *http r.Handle("/book/cover/{id:[0-9]+}", uisrv.withBook(uisrv.handleShowCover)) r.Handle("/book/thumb/{id:[0-9]+}", uisrv.withBook(uisrv.handleShowThumbnail)) r.Handle("/book/{id:[0-9]+}", uisrv.withBook(uisrv.handleShowBook)) - r.Handle("/dl/{id:[0-9]+}", uisrv.withBook(uisrv.handleDownloadBook)) + r.Handle("/dl/{id:[0-9]+}/{n:[0-9]+}", uisrv.withBook(uisrv.handleDownloadBook)) r.HandleFunc("/suggest", uisrv.handleAutocomplete) r.HandleFunc("/search", uisrv.handleSearch) r.HandleFunc("/", uisrv.handleHome) diff --git a/web_test.go b/web_test.go new file mode 100644 index 0000000..1562f98 --- /dev/null +++ b/web_test.go @@ -0,0 +1,94 @@ +package liber + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +type testHttpServer struct { + tmpdir string + td *testDatabase + db *Database +} + +func (ts *testHttpServer) Close() { + ts.td.Close() + os.RemoveAll(ts.tmpdir) +} + +func newTestHttpServer(t *testing.T) (*testHttpServer, *httptest.Server) { + var ts testHttpServer + + *htdocsDir = "./htdocs" + + ts.tmpdir, _ = ioutil.TempDir("", "tmp-storage-") + ts.td, ts.db = newTestDatabase(t) + + tempStorage := NewFileStorage(ts.tmpdir, 2) + server := NewHttpServer(ts.db, tempStorage, tempStorage, ":1234") + return &ts, httptest.NewServer(server.Handler) +} + +func readTestResponseData(resp *http.Response, t *testing.T) string { + data, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("Read response: %v", err) + } + return string(data) +} + +func TestWeb_Home(t *testing.T) { + ts, srv := newTestHttpServer(t) + defer srv.Close() + defer ts.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("Bad HTTP response: %s\n%s", resp.Status, readTestResponseData(resp, t)) + } +} + +func TestWeb_Search(t *testing.T) { + ts, srv := newTestHttpServer(t) + defer srv.Close() + defer ts.Close() + + resp, err := http.Get(srv.URL + "/search?q=jules") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("Bad HTTP response: %s\n%s", resp.Status, readTestResponseData(resp, t)) + } + data := readTestResponseData(resp, t) + if !strings.Contains(data, "20,000 Leagues") { + t.Fatalf("Response does not contain book title:\n%s", data) + } +} + +func TestWeb_ShowBook(t *testing.T) { + ts, srv := newTestHttpServer(t) + defer srv.Close() + defer ts.Close() + + resp, err := http.Get(fmt.Sprintf("%s/book/%d", srv.URL, ts.td.refbookid)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("Bad HTTP response: %s\n%s", resp.Status, readTestResponseData(resp, t)) + } + data := readTestResponseData(resp, t) + if !strings.Contains(data, "20,000 Leagues") { + t.Fatalf("Response does not contain book title:\n%s", data) + } +} -- GitLab