diff --git a/go.mod b/go.mod index 0427d84..399b096 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/arnef/ebooks go 1.25.0 -require github.com/a-h/templ v0.3.1020 +require ( + github.com/a-h/templ v0.3.1020 + github.com/bmaupin/go-epub v0.0.0-20210915022040-e113c1c5e4a3 +) diff --git a/internal/library/library.go b/internal/library/library.go index c0592b2..6f65d4c 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -1,9 +1,15 @@ package library import ( + "archive/zip" + "bytes" "crypto/sha1" "encoding/hex" + "encoding/xml" + "fmt" + "io" "os" + "path" "path/filepath" "sort" "strings" @@ -41,14 +47,32 @@ func (s *Service) ListBooks() ([]Book, error) { fullPath := filepath.Join(s.booksDir, e.Name()) id := stableID(e.Name()) - title := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + fallbackTitle := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + author := "Unbekannt" + hasCover := false + title := fallbackTitle + + if ext == ".epub" { + meta, err := readEPUBMetadata(fullPath) + if err == nil { + if strings.TrimSpace(meta.Title) != "" { + title = strings.TrimSpace(meta.Title) + } + if strings.TrimSpace(meta.Author) != "" { + author = strings.TrimSpace(meta.Author) + } + hasCover = meta.HasCover + } + } books = append(books, Book{ ID: id, Title: title, + Author: author, Filename: e.Name(), Path: fullPath, Format: strings.TrimPrefix(ext, "."), + HasCover: hasCover, }) } @@ -72,7 +96,219 @@ func (s *Service) FindBook(id string) (*Book, error) { return nil, nil } +func (s *Service) CoverBytes(id string) ([]byte, string, error) { + book, err := s.FindBook(id) + if err != nil { + return nil, "", err + } + if book == nil { + return nil, "", nil + } + if book.Format != "epub" { + return nil, "", nil + } + return readEPUBCover(book.Path) +} + func stableID(input string) string { h := sha1.Sum([]byte(strings.ToLower(input))) return hex.EncodeToString(h[:8]) } + +type epubMetadata struct { + Title string + Author string + HasCover bool +} + +type containerXML struct { + Rootfiles []struct { + FullPath string `xml:"full-path,attr"` + } `xml:"rootfiles>rootfile"` +} + +type packageXML struct { + Metadata struct { + Titles []string `xml:"title"` + Creators []string `xml:"creator"` + Metas []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` + } `xml:"metadata"` + Manifest struct { + Items []struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + MediaType string `xml:"media-type,attr"` + Properties string `xml:"properties,attr"` + } `xml:"item"` + } `xml:"manifest"` +} + +func readEPUBMetadata(filePath string) (epubMetadata, error) { + r, rootPath, pkg, err := openEPUBPackage(filePath) + if err != nil { + return epubMetadata{}, err + } + defer r.Close() + + meta := epubMetadata{} + if len(pkg.Metadata.Titles) > 0 { + meta.Title = strings.TrimSpace(pkg.Metadata.Titles[0]) + } + if len(pkg.Metadata.Creators) > 0 { + meta.Author = strings.TrimSpace(pkg.Metadata.Creators[0]) + } + + coverPath := findEPUBCoverPath(rootPath, pkg) + meta.HasCover = coverPath != "" + return meta, nil +} + +func readEPUBCover(filePath string) ([]byte, string, error) { + r, rootPath, pkg, err := openEPUBPackage(filePath) + if err != nil { + return nil, "", err + } + defer r.Close() + + coverPath := findEPUBCoverPath(rootPath, pkg) + if coverPath == "" { + return nil, "", nil + } + + for _, f := range r.File { + if path.Clean(f.Name) != coverPath { + continue + } + rc, err := f.Open() + if err != nil { + return nil, "", err + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return nil, "", err + } + return data, detectContentType(data, f.Name), nil + } + + return nil, "", nil +} + +func openEPUBPackage(filePath string) (*zip.ReadCloser, string, packageXML, error) { + r, err := zip.OpenReader(filePath) + if err != nil { + return nil, "", packageXML{}, err + } + + containerData, err := readZipFile(r.File, "META-INF/container.xml") + if err != nil { + r.Close() + return nil, "", packageXML{}, err + } + + var container containerXML + if err := xml.Unmarshal(containerData, &container); err != nil { + r.Close() + return nil, "", packageXML{}, err + } + if len(container.Rootfiles) == 0 || strings.TrimSpace(container.Rootfiles[0].FullPath) == "" { + r.Close() + return nil, "", packageXML{}, fmt.Errorf("missing rootfile in epub container") + } + + packagePath := path.Clean(container.Rootfiles[0].FullPath) + packageData, err := readZipFile(r.File, packagePath) + if err != nil { + r.Close() + return nil, "", packageXML{}, err + } + + var pkg packageXML + if err := xml.Unmarshal(packageData, &pkg); err != nil { + r.Close() + return nil, "", packageXML{}, err + } + + return r, path.Dir(packagePath), pkg, nil +} + +func findEPUBCoverPath(rootPath string, pkg packageXML) string { + coverID := "" + for _, meta := range pkg.Metadata.Metas { + if strings.EqualFold(strings.TrimSpace(meta.Name), "cover") { + coverID = strings.TrimSpace(meta.Content) + break + } + } + + for _, item := range pkg.Manifest.Items { + if strings.Contains(item.Properties, "cover-image") { + return cleanEPUBPath(rootPath, item.Href) + } + if coverID != "" && item.ID == coverID { + return cleanEPUBPath(rootPath, item.Href) + } + } + + for _, item := range pkg.Manifest.Items { + if strings.HasPrefix(item.MediaType, "image/") && strings.Contains(strings.ToLower(item.Href), "cover") { + return cleanEPUBPath(rootPath, item.Href) + } + } + + return "" +} + +func cleanEPUBPath(rootPath, href string) string { + joined := path.Clean(path.Join(rootPath, href)) + if strings.HasPrefix(joined, "../") { + return "" + } + return joined +} + +func readZipFile(files []*zip.File, name string) ([]byte, error) { + cleanName := path.Clean(name) + for _, f := range files { + if path.Clean(f.Name) != cleanName { + continue + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + return nil, os.ErrNotExist +} + +func detectContentType(data []byte, filename string) string { + if len(data) > 0 { + return httpDetectContentType(data) + } + ext := strings.ToLower(path.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + default: + return "application/octet-stream" + } +} + +func httpDetectContentType(data []byte) string { + return strings.TrimSpace(strings.SplitN(fmt.Sprintf("%s", bytes.TrimSpace([]byte(httpContentType(data)))), ";", 2)[0]) +} + +func httpContentType(data []byte) string { + return io.NopCloser(bytes.NewReader(data)).(interface{ Read([]byte) (int, error) }) // unreachable shim +} diff --git a/internal/library/model.go b/internal/library/model.go index c867b05..d1abf1c 100644 --- a/internal/library/model.go +++ b/internal/library/model.go @@ -3,7 +3,9 @@ package library type Book struct { ID string Title string + Author string Filename string Path string Format string + HasCover bool } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 41d6a19..67bf8d2 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /", h.listBooks) mux.HandleFunc("GET /book/{id}", h.bookDetails) mux.HandleFunc("GET /download/{id}", h.downloadBook) + mux.HandleFunc("GET /cover/{id}", h.bookCover) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) } @@ -84,6 +85,31 @@ func (h *Handler) downloadBook(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, cleanPath) } +func (h *Handler) bookCover(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.NotFound(w, r) + return + } + + data, contentType, err := h.lib.CoverBytes(id) + if err != nil { + http.Error(w, "failed to load cover", http.StatusInternalServerError) + return + } + if len(data) == 0 { + http.NotFound(w, r) + return + } + if contentType == "" { + contentType = "application/octet-stream" + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=3600") + _, _ = w.Write(data) +} + func render(w http.ResponseWriter, ctx context.Context, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := c.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) { diff --git a/static/styles.css b/static/styles.css index 9c5d92a..8045dca 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,20 +1,32 @@ :root { color-scheme: light; + --bg: #f6f4ee; + --surface: #ffffff; + --surface-muted: #f0ece2; + --text: #1d1b16; + --muted: #6b6458; + --border: #d6cebf; + --shadow: 0 2px 10px rgba(0, 0, 0, 0.06); } html, body { margin: 0; padding: 0; - background: #fff; - color: #000; + background: var(--bg); + color: var(--text); font-family: Arial, sans-serif; font-size: 18px; line-height: 1.45; } +img { + max-width: 100%; +} + .header { - border-bottom: 2px solid #000; - padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); + padding: 0.9rem 1rem; } .header h1 { @@ -29,51 +41,174 @@ html, body { .container { padding: 1rem; - max-width: 42rem; + max-width: 56rem; + margin: 0 auto; } h2 { margin-top: 0; - font-size: 1.25rem; + font-size: 1.4rem; +} + +.page-head { + margin-bottom: 1rem; +} + +.page-subtitle { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.95rem; } .book-list { list-style: none; margin: 0; padding: 0; + display: grid; + gap: 0.85rem; } .book-item { - border: 1px solid #000; - margin-bottom: 0.6rem; - padding: 0.6rem; + margin: 0; } -.book-link { - display: inline-block; - min-height: 44px; - color: #000; +.book-card { + display: grid; + grid-template-columns: 5.5rem 1fr; + gap: 0.9rem; + align-items: start; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + padding: 0.8rem; + color: inherit; text-decoration: none; + box-shadow: var(--shadow); +} + +.book-card-cover, +.book-detail-cover { + display: flex; + align-items: flex-start; + justify-content: center; +} + +.book-cover, +.book-cover-placeholder { + width: 100%; + max-width: 5.5rem; + aspect-ratio: 2 / 3; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-muted); + object-fit: cover; + overflow: hidden; +} + +.book-cover-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.85rem; font-weight: bold; + letter-spacing: 0.08em; +} + +.book-cover-detail, +.book-cover-placeholder-detail { + max-width: 12rem; +} + +.book-card-body { + min-width: 0; +} + +.book-title { + display: block; + font-size: 1.05rem; +} + +.book-author, +.book-author-detail { + display: block; + color: var(--muted); +} + +.book-author { + margin-top: 0.2rem; + font-size: 0.95rem; +} + +.book-author-detail { + margin: -0.35rem 0 1rem; } .format { - display: block; - margin-top: 0.2rem; - font-size: 0.95rem; + display: inline-block; + margin-top: 0.6rem; + padding: 0.15rem 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.85rem; + text-transform: uppercase; + background: var(--surface-muted); +} + +.book-detail { + display: grid; + gap: 1.25rem; +} + +.book-detail-body { + min-width: 0; +} + +.book-meta { + margin: 0 0 1.25rem; +} + +.book-meta div { + margin-bottom: 0.8rem; +} + +.book-meta dt { + font-weight: bold; +} + +.book-meta dd { + margin: 0.2rem 0 0; + color: var(--muted); + word-break: break-word; +} + +.back-link { + color: var(--muted); + text-decoration: none; } .btn { display: inline-block; min-height: 44px; padding: 0.7rem 1rem; - border: 2px solid #000; + border: 1px solid var(--text); + border-radius: 999px; text-decoration: none; - color: #000; + color: #fff; + background: var(--text); font-weight: bold; } code { - border: 1px solid #000; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); padding: 0.1rem 0.3rem; } + +@media (min-width: 700px) { + .book-detail { + grid-template-columns: 12rem 1fr; + align-items: start; + } +} diff --git a/views/pages.templ b/views/pages.templ index 4ec7864..7eed056 100644 --- a/views/pages.templ +++ b/views/pages.templ @@ -22,17 +22,38 @@ templ Layout(title string) { } +templ BookCover(book library.Book, detail bool) { + if book.HasCover { + { + } else { + + } +} + templ IndexPage(books []library.Book) { @Layout("Bibliothek") { -

Meine Bücher

+
+

Meine Bücher

+

Titel, Autor und Cover auf einen Blick.

+
if len(books) == 0 {

Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner books/ ab.

} else { @@ -42,12 +63,40 @@ templ IndexPage(books []library.Book) { templ BookPage(book library.Book) { @Layout(book.Title) { -
-

{ book.Title }

-

Datei: { book.Filename }

-

Format: { book.Format }

-

Auf Tolino herunterladen

-

← Zurück zur Liste

+
+
+ @BookCover(book, true) +
+
+

← Zurück zur Liste

+

{ book.Title }

+

{ book.Author }

+
+
+
Datei
+
{ book.Filename }
+
+
+
Format
+
{ book.Format }
+
+
+

Auf Tolino herunterladen

+
} } + +func coverClass(detail bool) string { + if detail { + return "book-cover book-cover-detail" + } + return "book-cover" +} + +func placeholderClass(detail bool) string { + if detail { + return "book-cover-placeholder book-cover-placeholder-detail" + } + return "book-cover-placeholder" +}