Phase 4: CBZ comics module and reader
- comics: reads pages straight out of the archive on demand. No extraction pass, so a 200-page CBZ is not materialised for a reader who views three pages. Pages are read into memory and served via http.ServeContent, which gives Range for free. - natural page ordering: page2 sorts before page10. Lexical ordering shuffles a numbered archive, which is the difference between a readable book and a scrambled one. - skips __MACOSX and dotfiles; rejects archives with no images. - cover is the first page. - media.Item gained ModTime (from the backing file) so page responses carry a Last-Modified and support conditional GETs. - web: ComicReader with a bounded prefetch window (two pages each way), page jump, keyboard nav, and progress stored as a page number. Verified: 3-page CBZ served in natural order, out-of-range 404, cover from page 1, progress round-trip. svelte-check 0/0.
This commit is contained in:
parent
7a5584a83f
commit
c3b7db7114
10 changed files with 594 additions and 5 deletions
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/httpapi"
|
"github.com/mithral/vellum/internal/httpapi"
|
||||||
"github.com/mithral/vellum/internal/library"
|
"github.com/mithral/vellum/internal/library"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
|
"github.com/mithral/vellum/internal/modules/comics"
|
||||||
"github.com/mithral/vellum/internal/modules/ebooks"
|
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
)
|
)
|
||||||
|
|
@ -75,6 +76,7 @@ func run() error {
|
||||||
library.NewHandlers(db, log, authenticator).Register(api.Mux())
|
library.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
|
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
|
||||||
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
|
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
|
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrItemNotFound means no item matched.
|
// ErrItemNotFound means no item matched.
|
||||||
|
|
@ -26,6 +27,10 @@ type Item struct {
|
||||||
|
|
||||||
// RootPath is the absolute library root, so AbsPath can be computed.
|
// RootPath is the absolute library root, so AbsPath can be computed.
|
||||||
RootPath string
|
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.
|
// 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.
|
// LoadItem loads an item and its library root.
|
||||||
func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
|
func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
|
||||||
var it Item
|
var it Item
|
||||||
|
var mtime string
|
||||||
err := db.QueryRowContext(ctx, `
|
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
|
FROM media_items i
|
||||||
JOIN libraries l ON l.id = i.library_id
|
JOIN libraries l ON l.id = i.library_id
|
||||||
WHERE i.id = ?`, 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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Item{}, ErrItemNotFound
|
return Item{}, ErrItemNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Item{}, fmt.Errorf("media: load item: %w", err)
|
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
|
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.
|
// FileEntry is one physical file backing an item.
|
||||||
type FileEntry struct {
|
type FileEntry struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
|
||||||
139
internal/modules/comics/comic.go
Normal file
139
internal/modules/comics/comic.go
Normal file
|
|
@ -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' }
|
||||||
102
internal/modules/comics/comic_test.go
Normal file
102
internal/modules/comics/comic_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/modules/comics/cover.go
Normal file
52
internal/modules/comics/cover.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
115
internal/modules/comics/handlers.go
Normal file
115
internal/modules/comics/handlers.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
3
web/dist/.gitkeep
vendored
3
web/dist/.gitkeep
vendored
|
|
@ -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.
|
|
||||||
|
|
@ -130,6 +130,11 @@ export const api = {
|
||||||
|
|
||||||
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
|
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`,
|
coverUrl: (id: number) => `/api/v1/items/${id}/cover`,
|
||||||
|
|
||||||
fileUrl: (id: number) => `/api/v1/items/${id}/file`,
|
fileUrl: (id: number) => `/api/v1/items/${id}/file`,
|
||||||
|
|
|
||||||
148
web/src/lib/components/ComicReader.svelte
Normal file
148
web/src/lib/components/ComicReader.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { api, ApiError } from '../api'
|
||||||
|
|
||||||
|
let { itemId }: { itemId: number } = $props()
|
||||||
|
|
||||||
|
let count = $state(0)
|
||||||
|
let current = $state(1)
|
||||||
|
let loading = $state(true)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
let pageInput = $state('1')
|
||||||
|
|
||||||
|
let saveTimer: number | undefined
|
||||||
|
const preloaded = new Set<string>()
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [list, prog] = await Promise.all([api.pages(itemId), api.progress(itemId)])
|
||||||
|
count = list.count
|
||||||
|
if (prog.progress && prog.progress.position > 0 && prog.progress.position <= count) {
|
||||||
|
current = prog.progress.position
|
||||||
|
}
|
||||||
|
pageInput = String(current)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof ApiError ? err.message : 'could not open this comic'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prefetch a window ahead and behind so a fast reader never sees a blank
|
||||||
|
// page. Bounded to two in each direction to keep memory flat on weak clients.
|
||||||
|
$effect(() => {
|
||||||
|
if (count === 0) return
|
||||||
|
for (const offset of [1, 2, -1, -2]) {
|
||||||
|
const n = current + offset
|
||||||
|
if (n < 1 || n > count) continue
|
||||||
|
const url = api.pageUrl(itemId, n)
|
||||||
|
if (preloaded.has(url)) continue
|
||||||
|
preloaded.add(url)
|
||||||
|
const img = new Image()
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function goTo(n: number) {
|
||||||
|
const next = Math.min(Math.max(n, 1), count)
|
||||||
|
if (next === current) return
|
||||||
|
current = next
|
||||||
|
pageInput = String(next)
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowRight' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
goTo(current + 1)
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowLeft') goTo(current - 1)
|
||||||
|
if (event.key === 'Home') goTo(1)
|
||||||
|
if (event.key === 'End') goTo(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = window.setTimeout(flush, 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (count === 0) return
|
||||||
|
void api.putProgress(itemId, { position: current, percent: current / count }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitJump(event: SubmitEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const n = parseInt(pageInput, 10)
|
||||||
|
if (!Number.isNaN(n)) goTo(n)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert">{error}</div>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="stage center"><div class="spinner"></div></div>
|
||||||
|
{:else}
|
||||||
|
<div class="stage">
|
||||||
|
<img class="page" src={api.pageUrl(itemId, current)} alt={`Page ${current}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bar">
|
||||||
|
<button class="btn btn-ghost" onclick={() => goTo(current - 1)} disabled={current <= 1}>←</button>
|
||||||
|
<form class="jump" onsubmit={submitJump}>
|
||||||
|
<input class="input page-input" bind:value={pageInput} aria-label="Page number" />
|
||||||
|
<span class="muted mono">/ {count}</span>
|
||||||
|
</form>
|
||||||
|
<span class="pct mono">{Math.round((current / count) * 100)}%</span>
|
||||||
|
<button class="btn btn-ghost" onclick={() => goTo(current + 1)} disabled={current >= count}>→</button>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stage {
|
||||||
|
height: calc(100vh - 52px - 96px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #000;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
.jump {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.page-input {
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pct {
|
||||||
|
color: var(--text-faint);
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import ePub from 'epubjs'
|
import ePub from 'epubjs'
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { api, ApiError, type Item, type EbookMetadata } from '../api'
|
import { api, ApiError, type Item, type EbookMetadata } from '../api'
|
||||||
|
import ComicReader from './ComicReader.svelte'
|
||||||
|
|
||||||
let { itemId }: { itemId: number } = $props()
|
let { itemId }: { itemId: number } = $props()
|
||||||
|
|
||||||
|
|
@ -89,6 +90,8 @@
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert">{error}</div>
|
<div class="alert">{error}</div>
|
||||||
|
{:else if item && item.kind === 'comics'}
|
||||||
|
<ComicReader {itemId} />
|
||||||
{:else if item && item.kind !== 'ebooks'}
|
{:else if item && item.kind !== 'ebooks'}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>{item.title}</h3>
|
<h3>{item.title}</h3>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue