diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 1ed253a..d10c125 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -16,6 +16,8 @@ import ( "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/library" + "github.com/mithral/vellum/internal/media" + "github.com/mithral/vellum/internal/modules/ebooks" "github.com/mithral/vellum/migrations" ) @@ -71,6 +73,8 @@ func run() error { api.SetAuth(authenticator) 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()) srv := &http.Server{ Addr: cfg.Listen, diff --git a/internal/media/covers.go b/internal/media/covers.go new file mode 100644 index 0000000..c85b8bd --- /dev/null +++ b/internal/media/covers.go @@ -0,0 +1,82 @@ +package media + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sync" +) + +// CoverExtractor pulls cover art out of a media file and writes it under +// cacheDir, returning the path to the written image. +// +// Modules register one per media kind. It runs lazily on first request and the +// result is recorded in the assets table, so the cost is paid once. +type CoverExtractor func(ctx context.Context, db *sql.DB, item Item, absPath, cacheDir string) (string, error) + +var ( + coverMu sync.RWMutex + coverExtractors = map[string]CoverExtractor{} +) + +// RegisterCoverExtractor installs the extractor for a media kind. A later +// registration replaces an earlier one. +func RegisterCoverExtractor(kind string, fn CoverExtractor) { + coverMu.Lock() + defer coverMu.Unlock() + coverExtractors[kind] = fn +} + +// CoverPath returns the cached cover path for an item, extracting it if +// necessary. The bool is false when no cover could be produced — which is a +// normal outcome, not an error, so callers return a placeholder rather than a +// 500. +func CoverPath(ctx context.Context, db *sql.DB, item Item, cacheDir string) (string, bool, error) { + // Already extracted and recorded? + var path string + err := db.QueryRowContext(ctx, ` + SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, item.ID).Scan(&path) + if err == nil { + if _, statErr := os.Stat(path); statErr == nil { + return path, true, nil + } + // Recorded but gone: fall through and re-extract. + } else if err != sql.ErrNoRows { + return "", false, fmt.Errorf("media: lookup cover: %w", err) + } + + coverMu.RLock() + extract, ok := coverExtractors[item.Kind] + coverMu.RUnlock() + if !ok { + return "", false, nil + } + + outPath, err := extract(ctx, db, item, item.AbsPath(), cacheDir) + if err != nil { + return "", false, fmt.Errorf("media: extract cover: %w", err) + } + if outPath == "" { + return "", false, nil + } + + if _, err := db.ExecContext(ctx, ` + INSERT INTO assets (item_id, role, path) VALUES (?, 'cover', ?) + ON CONFLICT (item_id, role, path) DO NOTHING`, item.ID, outPath); err != nil { + return "", false, fmt.Errorf("media: record cover: %w", err) + } + return outPath, true, nil +} + +// ── helpers for extractors ───────────────────────────────────────────── + +// CoverCacheDir returns (and creates) the per-kind cover cache directory. +func CoverCacheDir(cacheDir, kind string) (string, error) { + dir := filepath.Join(cacheDir, "covers", kind) + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", err + } + return dir, nil +} diff --git a/internal/media/handlers.go b/internal/media/handlers.go new file mode 100644 index 0000000..ec24f11 --- /dev/null +++ b/internal/media/handlers.go @@ -0,0 +1,278 @@ +package media + +import ( + "database/sql" + "errors" + "log/slog" + "net/http" + "os" + "strconv" + + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/progress" + "github.com/mithral/vellum/internal/stream" +) + +// Handlers serves the generic per-item endpoints. +type Handlers struct { + db *sql.DB + log *slog.Logger + auth *auth.Authenticator + cacheDir string +} + +// NewHandlers builds the media HTTP layer. cacheDir is where covers and +// extracted pages are written. +func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string) *Handlers { + return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir} +} + +// Register mounts the routes. +func (h *Handlers) Register(mux *http.ServeMux) { + mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem)) + mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile)) + mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover)) + mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress)) + mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress)) + mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent)) +} + +func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + files, err := h.files(r, it.ID) + if err != nil { + h.fail(w, "list files", err) + return + } + + prog, has, err := progress.Get(r.Context(), h.db, u.ID, it.ID) + if err != nil { + h.fail(w, "get progress", err) + return + } + + out := map[string]any{ + "item": map[string]any{ + "id": it.ID, + "library_id": it.LibraryID, + "kind": it.Kind, + "path": it.Path, + "title": it.Title, + "year": it.Year, + "missing": it.Missing, + }, + "files": files, + "has_cover": h.hasCover(r, it), + } + if has { + out["progress"] = prog + } + httpx.WriteJSON(w, http.StatusOK, out) +} + +func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + // Toolbar download vs inline. Inline is the default for readers. + disposition := "inline" + if r.URL.Query().Get("download") == "1" { + disposition = "attachment" + allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID) + if err != nil || !allowed { + httpx.WriteError(w, http.StatusForbidden, "download not permitted") + return + } + } + w.Header().Set("Content-Disposition", disposition+`; filename="`+sanitizeFilename(it.Path)+`"`) + + if err := stream.ServeFile(w, r, it.AbsPath()); err != nil { + if errors.Is(err, os.ErrNotExist) { + httpx.WriteError(w, http.StatusNotFound, "file is not available") + return + } + if errors.Is(err, stream.ErrNotAFile) { + httpx.WriteError(w, http.StatusBadRequest, "item is not a file") + return + } + h.fail(w, "serve file", err) + return + } +} + +func (h *Handlers) handleCover(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + path, found, err := CoverPath(r.Context(), h.db, it, h.cacheDir) + if err != nil { + h.fail(w, "cover", err) + return + } + if !found { + httpx.WriteError(w, http.StatusNotFound, "no cover available") + return + } + w.Header().Set("Cache-Control", "public, max-age=86400") + if err := stream.ServeFile(w, r, path); err != nil { + h.fail(w, "serve cover", err) + return + } +} + +func (h *Handlers) handleGetProgress(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + prog, found, err := progress.Get(r.Context(), h.db, u.ID, it.ID) + if err != nil { + h.fail(w, "get progress", err) + return + } + if !found { + httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": nil}) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": prog}) +} + +func (h *Handlers) handlePutProgress(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + var req struct { + FileID int64 `json:"file_id"` + Position *int64 `json:"position"` + Duration int64 `json:"duration"` + Percent float64 `json:"percent"` + Finished bool `json:"finished"` + } + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + + p := progress.Progress{ + ItemID: it.ID, + FileID: req.FileID, + Kind: it.Kind, + Duration: req.Duration, + Percent: req.Percent, + Finished: req.Finished, + } + if req.Position != nil { + p.Position = *req.Position + } + + saved, err := progress.Put(r.Context(), h.db, u.ID, p) + if errors.Is(err, progress.ErrNoFile) { + httpx.WriteError(w, http.StatusConflict, "item has no file to track progress against") + return + } + if err != nil { + h.fail(w, "save progress", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": saved}) +} + +func (h *Handlers) handleRecent(w http.ResponseWriter, r *http.Request, u auth.User) { + items, err := progress.RecentlyUpdated(r.Context(), h.db, u.ID, 20) + if err != nil { + h.fail(w, "recent progress", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": items}) +} + +// ── helpers ──────────────────────────────────────────────────────────── + +// authorized loads an item and confirms the caller may read its library. +// On failure it has already written the response. +func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (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 Item{}, false + } + + it, err := LoadItem(r.Context(), h.db, id) + if errors.Is(err, ErrItemNotFound) { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return Item{}, false + } + if err != nil { + h.fail(w, "load item", err) + return Item{}, false + } + + allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID) + if err != nil { + h.fail(w, "check access", err) + return Item{}, false + } + if !allowed { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return Item{}, false + } + return it, true +} + +func (h *Handlers) files(r *http.Request, itemID int64) ([]FileEntry, error) { + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, path, size, container, missing FROM media_files WHERE item_id = ? ORDER BY id`, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []FileEntry{} + for rows.Next() { + var f FileEntry + var missing int + if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &missing); err != nil { + return nil, err + } + f.Missing = missing == 1 + out = append(out, f) + } + return out, rows.Err() +} + +func (h *Handlers) hasCover(r *http.Request, it Item) bool { + var n int + _ = h.db.QueryRowContext(r.Context(), + `SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&n) + return n > 0 +} + +func (h *Handlers) fail(w http.ResponseWriter, what string, err error) { + h.log.Error(what, "err", err) + httpx.WriteError(w, http.StatusInternalServerError, "internal server error") +} + +// sanitizeFilename strips characters that break a Content-Disposition header. +func sanitizeFilename(name string) string { + out := make([]rune, 0, len(name)) + for _, r := range name { + switch { + case r == '"' || r == '\\' || r == '\n' || r == '\r': + out = append(out, '_') + default: + out = append(out, r) + } + } + return string(out) +} diff --git a/internal/media/media.go b/internal/media/media.go new file mode 100644 index 0000000..6eeb5fc --- /dev/null +++ b/internal/media/media.go @@ -0,0 +1,71 @@ +// Package media serves generic item endpoints: detail, the raw file, cover +// art, and progress. It knows nothing about any specific media format; modules +// register cover extractors and expose their own metadata endpoints. +package media + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" +) + +// ErrItemNotFound means no item matched. +var ErrItemNotFound = errors.New("media: item not found") + +// Item is one media item plus the root of its library. +type Item struct { + ID int64 + LibraryID int64 + Kind string + Path string // path relative to the library root + Title string + Year int + Missing bool + + // RootPath is the absolute library root, so AbsPath can be computed. + RootPath string +} + +// AbsPath returns the absolute filesystem path of the item's file. +// +// item.path is stored relative to the library root and the root is stored +// resolved, so this join cannot escape the library. +func (i Item) AbsPath() string { + return filepath.Join(i.RootPath, filepath.FromSlash(i.Path)) +} + +// LoadItem loads an item and its library root. +func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) { + var it Item + 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 + 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) + if errors.Is(err, sql.ErrNoRows) { + return Item{}, ErrItemNotFound + } + if err != nil { + return Item{}, fmt.Errorf("media: load item: %w", err) + } + return it, nil +} + +// FileEntry is one physical file backing an item. +type FileEntry struct { + ID int64 `json:"id"` + Path string `json:"path"` + Size int64 `json:"size"` + Container string `json:"container"` + Missing bool `json:"missing"` +} + +// FileCount returns how many files back an item. +func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) { + var n int + err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n) + return n, err +} diff --git a/internal/modules/ebooks/cover.go b/internal/modules/ebooks/cover.go new file mode 100644 index 0000000..7614065 --- /dev/null +++ b/internal/modules/ebooks/cover.go @@ -0,0 +1,73 @@ +package ebooks + +import ( + "context" + "database/sql" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/mithral/vellum/internal/media" +) + +// Kind is the media kind this module owns. +const Kind = "ebooks" + +func init() { + media.RegisterCoverExtractor(Kind, ExtractCover) +} + +// ExtractCover writes the EPUB's cover image into the cache and returns its +// path. A missing cover is not an error: it returns ("", nil) and the caller +// serves a placeholder. +func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) { + md, err := Parse(absPath) + if err != nil { + return "", err + } + if md.CoverHref == "" { + return "", nil + } + + dir, err := media.CoverCacheDir(cacheDir, Kind) + if err != nil { + return "", err + } + + ext := strings.ToLower(path.Ext(md.CoverHref)) + if ext == "" { + ext = ".jpg" + } + outPath := filepath.Join(dir, fmt.Sprintf("%d%s", item.ID, ext)) + + rc, _, err := OpenEntry(absPath, md.CoverHref) + if err != nil { + // Cover declared but not present in the archive: not fatal. + return "", nil + } + defer rc.Close() + + tmp := outPath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return "", fmt.Errorf("ebooks: create cover: %w", err) + } + if _, err := io.Copy(f, io.LimitReader(rc, 32<<20)); err != nil { + f.Close() + _ = os.Remove(tmp) + return "", fmt.Errorf("ebooks: write cover: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return "", err + } + // Atomic replace so a concurrent reader never sees a half-written image. + if err := os.Rename(tmp, outPath); err != nil { + _ = os.Remove(tmp) + return "", err + } + return outPath, nil +} diff --git a/internal/modules/ebooks/epub.go b/internal/modules/ebooks/epub.go new file mode 100644 index 0000000..3dd1d36 --- /dev/null +++ b/internal/modules/ebooks/epub.go @@ -0,0 +1,237 @@ +// Package ebooks parses EPUB files and serves ebook-specific metadata. +// +// EPUB is a zip with an OPF package document describing metadata, a manifest, +// and a spine (reading order). Parsing is done on demand from the file itself; +// nothing here depends on a network service. +package ebooks + +import ( + "archive/zip" + "encoding/xml" + "fmt" + "io" + "path" + "strings" +) + +// Metadata is the subset of EPUB metadata Vellum uses. +type Metadata struct { + Title string `json:"title"` + Authors []string `json:"authors"` + Language string `json:"language"` + Identifier string `json:"identifier"` + Publisher string `json:"publisher"` + Date string `json:"date"` + Description string `json:"description"` + SpineLength int `json:"spine_length"` + + // CoverHref is the cover image path relative to the OPF directory. + CoverHref string `json:"-"` +} + +// containerXML is META-INF/container.xml. +type containerXML struct { + Rootfiles []struct { + FullPath string `xml:"full-path,attr"` + } `xml:"rootfiles>rootfile"` +} + +// opfXML is the package document. Field tags match by local name, so +// namespaced elements (dc:title, dc:creator) decode without prefix handling. +type opfXML struct { + Metadata struct { + Titles []string `xml:"title"` + Creators []string `xml:"creator"` + Language string `xml:"language"` + Identifier string `xml:"identifier"` + Publisher string `xml:"publisher"` + Date string `xml:"date"` + Description string `xml:"description"` + Metas []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + Property string `xml:"property,attr"` + Value string `xml:",chardata"` + } `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"` + Spine struct { + Itemrefs []struct { + IDRef string `xml:"idref,attr"` + } `xml:"itemref"` + } `xml:"spine"` +} + +// Parse reads an EPUB's metadata and cover location. +func Parse(epubPath string) (Metadata, error) { + zr, err := zip.OpenReader(epubPath) + if err != nil { + return Metadata{}, fmt.Errorf("ebooks: open epub: %w", err) + } + defer zr.Close() + + opfPath, err := findOPFPath(zr) + if err != nil { + return Metadata{}, err + } + + data, err := readZipFile(zr, opfPath) + if err != nil { + return Metadata{}, fmt.Errorf("ebooks: read opf %s: %w", opfPath, err) + } + + var opf opfXML + if err := xml.Unmarshal(data, &opf); err != nil { + return Metadata{}, fmt.Errorf("ebooks: parse opf: %w", err) + } + + md := Metadata{ + Language: strings.TrimSpace(opf.Metadata.Language), + Identifier: strings.TrimSpace(opf.Metadata.Identifier), + Publisher: strings.TrimSpace(opf.Metadata.Publisher), + Date: strings.TrimSpace(opf.Metadata.Date), + Description: strings.TrimSpace(opf.Metadata.Description), + } + for _, t := range opf.Metadata.Titles { + if s := strings.TrimSpace(t); s != "" { + md.Title = s + break + } + } + for _, c := range opf.Metadata.Creators { + if s := strings.TrimSpace(c); s != "" { + md.Authors = append(md.Authors, s) + } + } + md.SpineLength = len(opf.Spine.Itemrefs) + md.CoverHref = coverHref(opf) + + // Hrefs in the OPF are relative to the OPF's directory. + md.CoverHref = joinOPF(path.Dir(opfPath), md.CoverHref) + return md, nil +} + +// findOPFPath locates the package document via META-INF/container.xml. +func findOPFPath(zr *zip.ReadCloser) (string, error) { + data, err := readZipFile(zr, "META-INF/container.xml") + if err != nil { + return "", fmt.Errorf("ebooks: read container.xml: %w", err) + } + var c containerXML + if err := xml.Unmarshal(data, &c); err != nil { + return "", fmt.Errorf("ebooks: parse container.xml: %w", err) + } + for _, rf := range c.Rootfiles { + if p := strings.TrimSpace(rf.FullPath); p != "" { + return p, nil + } + } + return "", fmt.Errorf("ebooks: container.xml has no rootfile") +} + +// coverHref picks the cover image from the manifest, trying in order: +// the EPUB3 properties="cover-image" marker, the EPUB2 +// reference, then any manifest image with "cover" in its id or href. +func coverHref(opf opfXML) string { + byID := make(map[string]string, len(opf.Manifest.Items)) + for _, it := range opf.Manifest.Items { + byID[it.ID] = it.Href + } + + for _, it := range opf.Manifest.Items { + if hasProp(it.Properties, "cover-image") { + return it.Href + } + } + for _, m := range opf.Metadata.Metas { + if strings.EqualFold(strings.TrimSpace(m.Name), "cover") { + if href, ok := byID[strings.TrimSpace(m.Content)]; ok { + return href + } + } + } + for _, it := range opf.Manifest.Items { + if !strings.HasPrefix(it.MediaType, "image/") { + continue + } + if strings.Contains(strings.ToLower(it.ID), "cover") || strings.Contains(strings.ToLower(it.Href), "cover") { + return it.Href + } + } + return "" +} + +func hasProp(props, want string) bool { + for _, p := range strings.Fields(props) { + if p == want { + return true + } + } + return false +} + +// joinOPF joins an OPF-relative href onto the OPF directory, using forward +// slashes (zip convention) and refusing to escape the archive root. +func joinOPF(dir, href string) string { + if href == "" { + return "" + } + joined := path.Clean(path.Join(dir, href)) + if strings.HasPrefix(joined, "../") || joined == ".." { + return "" + } + return joined +} + +// readZipFile reads one entry fully. +func readZipFile(zr *zip.ReadCloser, name string) ([]byte, error) { + f, err := zr.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, 16<<20)) // 16 MiB is generous for an OPF +} + +// OpenEntry opens one entry of the archive for streaming. +func OpenEntry(epubPath, name string) (io.ReadCloser, *zip.File, error) { + zr, err := zip.OpenReader(epubPath) + if err != nil { + return nil, nil, err + } + f, err := zr.Open(name) + if err != nil { + zr.Close() + return nil, nil, err + } + // Wrap so closing the reader also closes the archive. + return &entryReadCloser{rc: f, zr: zr}, findZipFile(zr, name), nil +} + +func findZipFile(zr *zip.ReadCloser, name string) *zip.File { + for _, f := range zr.File { + if f.Name == name { + return f + } + } + return nil +} + +type entryReadCloser struct { + rc io.ReadCloser + zr *zip.ReadCloser +} + +func (e *entryReadCloser) Read(p []byte) (int, error) { return e.rc.Read(p) } +func (e *entryReadCloser) Close() error { + err := e.rc.Close() + _ = e.zr.Close() + return err +} diff --git a/internal/modules/ebooks/epub_test.go b/internal/modules/ebooks/epub_test.go new file mode 100644 index 0000000..7790e34 --- /dev/null +++ b/internal/modules/ebooks/epub_test.go @@ -0,0 +1,141 @@ +package ebooks + +import ( + "archive/zip" + "encoding/xml" + "os" + "path/filepath" + "testing" +) + +const testOPF = ` + + + The Test Book + Ada Lovelace + Charles Babbage + en + urn:uuid:abc + Vellum Press + 2026-01-01 + + + + + + + + + + + +` + +const testContainer = ` + + +` + +func writeEPUB(t *testing.T, opf, container string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "test.epub") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + zw := zip.NewWriter(f) + add := func(name, body string) { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + add("mimetype", "application/epub+zip") + add("META-INF/container.xml", container) + add("OEBPS/content.opf", opf) + add("OEBPS/images/cover.png", "not-a-real-png") + add("OEBPS/chapter1.xhtml", "") + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func TestParseMetadata(t *testing.T) { + md, err := Parse(writeEPUB(t, testOPF, testContainer)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if md.Title != "The Test Book" { + t.Errorf("Title = %q", md.Title) + } + if len(md.Authors) != 2 || md.Authors[0] != "Ada Lovelace" { + t.Errorf("Authors = %v", md.Authors) + } + if md.Language != "en" || md.Publisher != "Vellum Press" || md.Date != "2026-01-01" { + t.Errorf("metadata = %+v", md) + } + if md.SpineLength != 2 { + t.Errorf("SpineLength = %d, want 2", md.SpineLength) + } + // CoverHref must be resolved relative to the OPF directory. + if md.CoverHref != "OEBPS/images/cover.png" { + t.Errorf("CoverHref = %q, want OEBPS/images/cover.png", md.CoverHref) + } +} + +func TestCoverHrefFallbacks(t *testing.T) { + // No properties="cover-image"; rely on . + opfMeta := `x + ` + if got := coverHref(mustParseOPF(t, opfMeta)); got != "c.jpg" { + t.Errorf("meta cover fallback = %q, want c.jpg", got) + } + + // No marker at all; fall back to an image whose href mentions cover. + opfPlain := `x + ` + if got := coverHref(mustParseOPF(t, opfPlain)); got != "images/my-cover.png" { + t.Errorf("href heuristics = %q, want images/my-cover.png", got) + } + + // No candidate at all. + opfNone := `x + ` + if got := coverHref(mustParseOPF(t, opfNone)); got != "" { + t.Errorf("no-cover case = %q, want empty", got) + } +} + +func TestJoinOPFRefusesEscape(t *testing.T) { + if got := joinOPF("OEBPS", "../../etc/passwd"); got != "" { + t.Errorf("joinOPF escaped the archive: %q", got) + } + if got := joinOPF("OEBPS", "images/c.png"); got != "OEBPS/images/c.png" { + t.Errorf("joinOPF = %q", got) + } +} + +func TestParseRejectsNonEPUB(t *testing.T) { + p := filepath.Join(t.TempDir(), "not.epub") + if err := os.WriteFile(p, []byte("this is not a zip"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Parse(p); err == nil { + t.Error("Parse succeeded on a non-zip file") + } +} + +func mustParseOPF(t *testing.T, s string) opfXML { + t.Helper() + var o opfXML + if err := xml.Unmarshal([]byte(s), &o); err != nil { + t.Fatal(err) + } + return o +} diff --git a/internal/modules/ebooks/handlers.go b/internal/modules/ebooks/handlers.go new file mode 100644 index 0000000..41deab6 --- /dev/null +++ b/internal/modules/ebooks/handlers.go @@ -0,0 +1,76 @@ +package ebooks + +import ( + "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" +) + +// Handlers serves ebook-specific endpoints. +type Handlers struct { + db *sql.DB + log *slog.Logger + auth *auth.Authenticator +} + +// NewHandlers builds the ebook 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}/ebook", h.auth.Require(h.handleMetadata)) +} + +// handleMetadata parses the EPUB on demand and returns its metadata. +// +// Parsing on demand rather than at scan time keeps the scanner fast and means +// a fix to the parser applies immediately, without a rescan. +func (h *Handlers) handleMetadata(w http.ResponseWriter, r *http.Request, u auth.User) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid item id") + return + } + + it, err := media.LoadItem(r.Context(), h.db, id) + if errors.Is(err, media.ErrItemNotFound) { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return + } + if err != nil { + h.log.Error("load item", "err", err) + httpx.WriteError(w, http.StatusInternalServerError, "internal server error") + return + } + + allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID) + if err != nil || !allowed { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return + } + if it.Kind != Kind { + httpx.WriteError(w, http.StatusBadRequest, "item is not an ebook") + return + } + + md, err := Parse(it.AbsPath()) + if err != nil { + h.log.Warn("parse epub", "item", it.ID, "err", err) + httpx.WriteError(w, http.StatusUnprocessableEntity, "could not parse epub") + return + } + + // Fall back to the scanner's title when the OPF has none. + if md.Title == "" { + md.Title = it.Title + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"metadata": md}) +} diff --git a/internal/progress/store.go b/internal/progress/store.go new file mode 100644 index 0000000..601c839 --- /dev/null +++ b/internal/progress/store.go @@ -0,0 +1,155 @@ +// Package progress stores and retrieves per-user playback/reading position. +// +// One table, one shape, every media kind: milliseconds for audio and video, a +// spine index or page number for text. Callers that don't fit the integer +// model should encode their position into it rather than adding a column. +package progress + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// ErrNoFile means an item has no media_files row to attach progress to. +var ErrNoFile = errors.New("progress: item has no file to track") + +// Progress is one user's position in one item's file. +type Progress struct { + ItemID int64 `json:"item_id"` + FileID int64 `json:"file_id"` + Kind string `json:"kind"` + Position int64 `json:"position"` + Duration int64 `json:"duration"` + Percent float64 `json:"percent"` + Finished bool `json:"finished"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Get returns the most recently updated progress row for a user and item. +// The second return value is false when the user has never touched the item. +func Get(ctx context.Context, db *sql.DB, userID, itemID int64) (Progress, bool, error) { + var ( + p Progress + finished int + updatedAt string + ) + err := db.QueryRowContext(ctx, ` + SELECT item_id, file_id, kind, position, duration, percent, finished, updated_at + FROM progress + WHERE user_id = ? AND item_id = ? + ORDER BY updated_at DESC + LIMIT 1`, userID, itemID, + ).Scan(&p.ItemID, &p.FileID, &p.Kind, &p.Position, &p.Duration, &p.Percent, &finished, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Progress{}, false, nil + } + if err != nil { + return Progress{}, false, fmt.Errorf("progress: get: %w", err) + } + p.Finished = finished == 1 + p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + return p, true, nil +} + +// Put upserts progress. A zero FileID resolves to the item's first file, which +// is what single-file items (epub, cbz) always are. +func Put(ctx context.Context, db *sql.DB, userID int64, p Progress) (Progress, error) { + if p.FileID == 0 { + fileID, err := firstFileID(ctx, db, p.ItemID) + if err != nil { + return Progress{}, err + } + p.FileID = fileID + } + if p.Kind == "" { + _ = db.QueryRowContext(ctx, `SELECT kind FROM media_items WHERE id = ?`, p.ItemID).Scan(&p.Kind) + } + + if p.Percent < 0 { + p.Percent = 0 + } + if p.Percent > 1 { + p.Percent = 1 + } + // A timed item at 100% is finished even if the client did not say so. + if p.Duration > 0 && p.Position >= p.Duration { + p.Percent = 1 + p.Finished = true + } + + now := time.Now().UTC() + _, err := db.ExecContext(ctx, ` + INSERT INTO progress (user_id, item_id, file_id, kind, position, duration, percent, finished, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, item_id, file_id) + DO UPDATE SET kind = excluded.kind, position = excluded.position, + duration = excluded.duration, percent = excluded.percent, + finished = excluded.finished, updated_at = excluded.updated_at`, + userID, p.ItemID, p.FileID, p.Kind, p.Position, p.Duration, p.Percent, boolInt(p.Finished), + now.Format(time.RFC3339Nano)) + if err != nil { + return Progress{}, fmt.Errorf("progress: put: %w", err) + } + p.UpdatedAt = now + return p, nil +} + +// Delete clears progress for a user and item. +func Delete(ctx context.Context, db *sql.DB, userID, itemID int64) error { + _, err := db.ExecContext(ctx, `DELETE FROM progress WHERE user_id = ? AND item_id = ?`, userID, itemID) + return err +} + +// RecentlyUpdated powers a "Continue reading/watching" shelf. +func RecentlyUpdated(ctx context.Context, db *sql.DB, userID int64, limit int) ([]Progress, error) { + if limit <= 0 || limit > 200 { + limit = 20 + } + rows, err := db.QueryContext(ctx, ` + SELECT item_id, file_id, kind, position, duration, percent, finished, updated_at + FROM progress + WHERE user_id = ? AND finished = 0 + ORDER BY updated_at DESC + LIMIT ?`, userID, limit) + if err != nil { + return nil, fmt.Errorf("progress: recent: %w", err) + } + defer rows.Close() + + out := []Progress{} + for rows.Next() { + var p Progress + var finished int + var updatedAt string + if err := rows.Scan(&p.ItemID, &p.FileID, &p.Kind, &p.Position, &p.Duration, &p.Percent, &finished, &updatedAt); err != nil { + return nil, err + } + p.Finished = finished == 1 + p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + out = append(out, p) + } + return out, rows.Err() +} + +func firstFileID(ctx context.Context, db *sql.DB, itemID int64) (int64, error) { + var id int64 + err := db.QueryRowContext(ctx, + `SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNoFile + } + if err != nil { + return 0, fmt.Errorf("progress: first file: %w", err) + } + return id, nil +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/stream/stream.go b/internal/stream/stream.go new file mode 100644 index 0000000..a70acaf --- /dev/null +++ b/internal/stream/stream.go @@ -0,0 +1,100 @@ +// Package stream serves media files with correct HTTP range semantics. +// +// Everything Vellum serves — epubs, comic pages, audio tracks, video — goes +// through http.ServeContent, which implements Range, If-Range, +// If-Modified-Since, and multi-range responses. Hand-rolling that is how a +// media server ends up with seek bugs in exactly one player. +package stream + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// ErrNotAFile means the requested path is a directory or otherwise unservable. +var ErrNotAFile = errors.New("stream: not a regular file") + +// contentTypes maps extensions to MIME types for the media Vellum serves. +// Unknown extensions fall back to application/octet-stream, which browsers +// download rather than mis-render. +var contentTypes = map[string]string{ + ".epub": "application/epub+zip", + ".pdf": "application/pdf", + // Comics are containers; pages are served as images, but the archive + // itself may be downloaded. + ".cbz": "application/vnd.comicbook+zip", + ".cbr": "application/vnd.comicbook-rar", + // Audio. + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".m4b": "audio/mp4", + ".aac": "audio/aac", + ".flac": "audio/flac", + ".opus": "audio/ogg", + ".ogg": "audio/ogg", + ".wav": "audio/wav", + // Video. + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".mkv": "video/x-matroska", + ".webm": "video/webm", + ".mov": "video/quicktime", + // Images (comic pages, covers, photos). + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + ".avif": "image/avif", + ".heic": "image/heic", + ".svg": "image/svg+xml", +} + +// ContentType returns the MIME type for a path by extension. +func ContentType(path string) string { + if ct, ok := contentTypes[strings.ToLower(filepath.Ext(path))]; ok { + return ct + } + return "application/octet-stream" +} + +// ServeFile streams a file from disk, honouring Range requests. +// +// It returns ErrNotAFile for directories. Other errors (missing file, denied +// permission) are returned before any response is written, so the caller can +// still choose a status code. +func ServeFile(w http.ResponseWriter, r *http.Request, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return ErrNotAFile + } + + w.Header().Set("Content-Type", ContentType(path)) + // Media is immutable in practice: a changed file gets a new mtime, so + // letting the browser cache aggressively is safe and saves bandwidth on + // large audio/video. + w.Header().Set("Accept-Ranges", "bytes") + + http.ServeContent(w, r, info.Name(), info.ModTime(), f) + return nil +} + +// ServeBytes writes an in-memory blob with a content type, mostly for +// generated covers and extracted comic pages. +func ServeBytes(w http.ResponseWriter, r *http.Request, name string, data []byte) { + w.Header().Set("Content-Type", ContentType(name)) + http.ServeContent(w, r, name, time.Time{}, strings.NewReader(string(data))) +}