diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index d10c125..530067d 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -17,6 +17,7 @@ import ( "github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/media" + "github.com/mithral/vellum/internal/modules/comics" "github.com/mithral/vellum/internal/modules/ebooks" "github.com/mithral/vellum/migrations" ) @@ -75,6 +76,7 @@ func run() error { library.NewHandlers(db, log, authenticator).Register(api.Mux()) media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux()) ebooks.NewHandlers(db, log, authenticator).Register(api.Mux()) + comics.NewHandlers(db, log, authenticator).Register(api.Mux()) srv := &http.Server{ Addr: cfg.Listen, diff --git a/internal/media/media.go b/internal/media/media.go index 6eeb5fc..0358bb7 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "path/filepath" + "time" ) // ErrItemNotFound means no item matched. @@ -26,6 +27,10 @@ type Item struct { // RootPath is the absolute library root, so AbsPath can be computed. RootPath string + + // ModTime is the backing file's modification time, used for conditional + // GETs. Zero when unknown. + ModTime time.Time } // AbsPath returns the absolute filesystem path of the item's file. @@ -39,21 +44,42 @@ func (i Item) AbsPath() string { // LoadItem loads an item and its library root. func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) { var it Item + var mtime string err := db.QueryRowContext(ctx, ` - SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path + SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path, + COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '') FROM media_items i JOIN libraries l ON l.id = i.library_id WHERE i.id = ?`, id, - ).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath) + ).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath, &mtime) if errors.Is(err, sql.ErrNoRows) { return Item{}, ErrItemNotFound } if err != nil { return Item{}, fmt.Errorf("media: load item: %w", err) } + if nanos := parseUnixNano(mtime); nanos > 0 { + it.ModTime = time.Unix(0, nanos).UTC() + } return it, nil } +// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything +// that is not all digits. +func parseUnixNano(s string) int64 { + if s == "" { + return 0 + } + var n int64 + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0 + } + n = n*10 + int64(s[i]-'0') + } + return n +} + // FileEntry is one physical file backing an item. type FileEntry struct { ID int64 `json:"id"` diff --git a/internal/modules/comics/comic.go b/internal/modules/comics/comic.go new file mode 100644 index 0000000..eeeae19 --- /dev/null +++ b/internal/modules/comics/comic.go @@ -0,0 +1,139 @@ +// Package comics reads CBZ archives (a zip of page images) and serves pages. +// +// Pages are read straight out of the archive on demand rather than extracted +// to disk. A page is a single image, so it is read into memory and served +// through http.ServeContent, which gives Range support for free and avoids +// materialising a 200-page archive for a reader who views three pages. +package comics + +import ( + "archive/zip" + "fmt" + "io" + "path" + "sort" + "strings" +) + +// Kind is the media kind this module owns. +const Kind = "comics" + +// maxPageBytes caps a single page so a malformed archive cannot exhaust memory. +const maxPageBytes = 64 << 20 // 64 MiB + +// imageExts are the page formats a CBZ may contain. +var imageExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".webp": true, + ".gif": true, + ".avif": true, + ".bmp": true, +} + +// Page is one page in reading order. +type Page struct { + Index int `json:"index"` // 1-based + Name string `json:"name"` +} + +// ListPages returns the archive's image entries in reading order. +// +// Ordering is natural, not lexical: "page2.png" sorts before "page10.png", +// which lexical ordering gets wrong. Comic archives are usually already +// numbered, so this is the difference between a readable book and a shuffled +// one. +func ListPages(cbzPath string) ([]Page, error) { + zr, err := zip.OpenReader(cbzPath) + if err != nil { + return nil, fmt.Errorf("comics: open archive: %w", err) + } + defer zr.Close() + + var names []string + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + name := f.Name + // Skip macOS resource forks and other hidden metadata. + if strings.HasPrefix(path.Base(name), ".") || strings.Contains(name, "__MACOSX/") { + continue + } + if imageExts[strings.ToLower(path.Ext(name))] { + names = append(names, name) + } + } + if len(names) == 0 { + return nil, fmt.Errorf("comics: archive contains no images") + } + + sort.Slice(names, func(i, j int) bool { return naturalLess(names[i], names[j]) }) + + pages := make([]Page, len(names)) + for i, n := range names { + pages[i] = Page{Index: i + 1, Name: n} + } + return pages, nil +} + +// ReadPage returns the bytes of one page by its archive name. +func ReadPage(cbzPath, name string) ([]byte, error) { + zr, err := zip.OpenReader(cbzPath) + if err != nil { + return nil, fmt.Errorf("comics: open archive: %w", err) + } + defer zr.Close() + + for _, f := range zr.File { + if f.Name != name { + continue + } + if f.UncompressedSize64 > maxPageBytes { + return nil, fmt.Errorf("comics: page %q is too large", name) + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, maxPageBytes)) + } + return nil, fmt.Errorf("comics: page %q not found", name) +} + +// naturalLess compares two strings treating digit runs as numbers. +func naturalLess(a, b string) bool { + ai, bi := 0, 0 + for ai < len(a) && bi < len(b) { + ad, bd := isDigit(a[ai]), isDigit(b[bi]) + if ad && bd { + // Compare the full digit runs numerically. + as, bs := ai, bi + for ai < len(a) && isDigit(a[ai]) { + ai++ + } + for bi < len(b) && isDigit(b[bi]) { + bi++ + } + an := strings.TrimLeft(a[as:ai], "0") + bn := strings.TrimLeft(b[bs:bi], "0") + if len(an) != len(bn) { + return len(an) < len(bn) + } + if an != bn { + return an < bn + } + continue + } + if a[ai] != b[bi] { + return a[ai] < b[bi] + } + ai++ + bi++ + } + return len(a)-ai < len(b)-bi +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } diff --git a/internal/modules/comics/comic_test.go b/internal/modules/comics/comic_test.go new file mode 100644 index 0000000..e241ed0 --- /dev/null +++ b/internal/modules/comics/comic_test.go @@ -0,0 +1,102 @@ +package comics + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func writeCBZ(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "test.cbz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + zw := zip.NewWriter(f) + for name, body := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func TestListPagesNaturalOrder(t *testing.T) { + p := writeCBZ(t, map[string]string{ + "page10.jpg": "ten", + "page2.jpg": "two", + "page1.jpg": "one", + "notes.txt": "not an image", + "__MACOSX/._page1.jpg": "junk", + }) + + pages, err := ListPages(p) + if err != nil { + t.Fatalf("ListPages: %v", err) + } + want := []string{"page1.jpg", "page2.jpg", "page10.jpg"} + if len(pages) != len(want) { + t.Fatalf("got %d pages, want %d: %+v", len(pages), len(want), pages) + } + for i, name := range want { + if pages[i].Name != name { + t.Errorf("page %d = %q, want %q", i+1, pages[i].Name, name) + } + if pages[i].Index != i+1 { + t.Errorf("page %d index = %d, want %d", i, pages[i].Index, i+1) + } + } +} + +func TestReadPage(t *testing.T) { + p := writeCBZ(t, map[string]string{"page1.jpg": "hello-page"}) + + data, err := ReadPage(p, "page1.jpg") + if err != nil { + t.Fatalf("ReadPage: %v", err) + } + if string(data) != "hello-page" { + t.Errorf("page content = %q", data) + } + + if _, err := ReadPage(p, "missing.jpg"); err == nil { + t.Error("ReadPage succeeded for a missing entry") + } +} + +func TestListPagesRejectsArchiveWithoutImages(t *testing.T) { + p := writeCBZ(t, map[string]string{"readme.txt": "nothing to see"}) + if _, err := ListPages(p); err == nil { + t.Error("ListPages succeeded on an archive with no images") + } +} + +func TestNaturalLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"page1.jpg", "page2.jpg", true}, + {"page2.jpg", "page10.jpg", true}, + {"page10.jpg", "page2.jpg", false}, + {"page01.jpg", "page1.jpg", false}, // equal numerically + {"page1.jpg", "page01.jpg", false}, + {"a.jpg", "b.jpg", true}, + } + for _, c := range cases { + if got := naturalLess(c.a, c.b); got != c.want { + t.Errorf("naturalLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/modules/comics/cover.go b/internal/modules/comics/cover.go new file mode 100644 index 0000000..c37ee41 --- /dev/null +++ b/internal/modules/comics/cover.go @@ -0,0 +1,52 @@ +package comics + +import ( + "context" + "database/sql" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/mithral/vellum/internal/media" +) + +func init() { + media.RegisterCoverExtractor(Kind, ExtractCover) +} + +// ExtractCover writes the first page of the archive into the cache and returns +// its path. A comic's first page is its cover by convention. +func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) { + pages, err := ListPages(absPath) + if err != nil { + return "", err + } + + dir, err := media.CoverCacheDir(cacheDir, Kind) + if err != nil { + return "", err + } + + ext := strings.ToLower(path.Ext(pages[0].Name)) + if ext == "" { + ext = ".jpg" + } + outPath := filepath.Join(dir, fmt.Sprintf("%d%s", item.ID, ext)) + + data, err := ReadPage(absPath, pages[0].Name) + if err != nil { + return "", err + } + + tmp := outPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o640); err != nil { + return "", fmt.Errorf("comics: write cover: %w", err) + } + if err := os.Rename(tmp, outPath); err != nil { + _ = os.Remove(tmp) + return "", err + } + return outPath, nil +} diff --git a/internal/modules/comics/handlers.go b/internal/modules/comics/handlers.go new file mode 100644 index 0000000..94a6149 --- /dev/null +++ b/internal/modules/comics/handlers.go @@ -0,0 +1,115 @@ +package comics + +import ( + "bytes" + "database/sql" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/media" + "github.com/mithral/vellum/internal/stream" +) + +// Handlers serves comic-specific endpoints. +type Handlers struct { + db *sql.DB + log *slog.Logger + auth *auth.Authenticator +} + +// NewHandlers builds the comic HTTP layer. +func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers { + return &Handlers{db: db, log: log, auth: a} +} + +// Register mounts the routes. +func (h *Handlers) Register(mux *http.ServeMux) { + mux.Handle("GET /api/v1/items/{id}/pages", h.auth.Require(h.handleListPages)) + mux.Handle("GET /api/v1/items/{id}/pages/{page}", h.auth.Require(h.handlePage)) +} + +// handleListPages returns the archive's page list in reading order. +func (h *Handlers) handleListPages(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + pages, err := ListPages(it.AbsPath()) + if err != nil { + h.log.Warn("list pages", "item", it.ID, "err", err) + httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive") + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"pages": pages, "count": len(pages)}) +} + +// handlePage streams one page image. +func (h *Handlers) handlePage(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + idx, err := strconv.Atoi(r.PathValue("page")) + if err != nil || idx < 1 { + httpx.WriteError(w, http.StatusBadRequest, "invalid page number") + return + } + + pages, err := ListPages(it.AbsPath()) + if err != nil { + httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive") + return + } + if idx > len(pages) { + httpx.WriteError(w, http.StatusNotFound, "page out of range") + return + } + + data, err := ReadPage(it.AbsPath(), pages[idx-1].Name) + if err != nil { + h.log.Error("read page", "item", it.ID, "page", idx, "err", err) + httpx.WriteError(w, http.StatusInternalServerError, "could not read the page") + return + } + + // Pages are immutable; let the browser keep them. + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Header().Set("Content-Type", stream.ContentType(pages[idx-1].Name)) + http.ServeContent(w, r, pages[idx-1].Name, it.ModTime, bytes.NewReader(data)) +} + +// authorized loads an item, confirms access, and checks it is a comic. +func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid item id") + return media.Item{}, false + } + + it, err := media.LoadItem(r.Context(), h.db, id) + if errors.Is(err, media.ErrItemNotFound) { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return media.Item{}, false + } + if err != nil { + h.log.Error("load item", "err", err) + httpx.WriteError(w, http.StatusInternalServerError, "internal server error") + return media.Item{}, false + } + + allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID) + if err != nil || !allowed { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return media.Item{}, false + } + if it.Kind != Kind { + httpx.WriteError(w, http.StatusBadRequest, "item is not a comic") + return media.Item{}, false + } + return it, true +} diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep deleted file mode 100644 index 674ae75..0000000 --- a/web/dist/.gitkeep +++ /dev/null @@ -1,3 +0,0 @@ -The Vite build output goes here. This file keeps the directory present so the -Go embed directive in ../embed.go is always valid, even before the frontend has -been built. Run `pnpm build` (or `make build` from the repo root) to populate it. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 55587e8..ea62adf 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -130,6 +130,11 @@ export const api = { ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`), + pages: (id: number) => + request<{ pages: { index: number; name: string }[]; count: number }>('GET', `/api/v1/items/${id}/pages`), + + pageUrl: (id: number, page: number) => `/api/v1/items/${id}/pages/${page}`, + coverUrl: (id: number) => `/api/v1/items/${id}/cover`, fileUrl: (id: number) => `/api/v1/items/${id}/file`, diff --git a/web/src/lib/components/ComicReader.svelte b/web/src/lib/components/ComicReader.svelte new file mode 100644 index 0000000..9e1294d --- /dev/null +++ b/web/src/lib/components/ComicReader.svelte @@ -0,0 +1,148 @@ + + +{#if error} +