Phase 3: EPUB module, file streaming, per-user progress
- stream: serving goes through http.ServeContent, so Range/If-Range/ If-Modified-Since are correct for every media type by construction - progress: the polymorphic store. One row per user/item/file; the PK includes the file so multi-track audiobooks resume per track later. Finished is inferred when position reaches duration. - media: generic item endpoints (detail, file, cover, progress, recent). Every one routes through auth.CanAccessLibrary; an unauthorised item 404s. - covers: a per-kind extractor registry. Extraction is lazy and recorded in the assets table. A missing cover is a placeholder, never a 500. - modules/ebooks: EPUB parsing (container.xml -> OPF -> metadata, spine, cover with three fallback strategies), cover extraction to /config, and lazy metadata parsing so parser fixes need no rescan. Verified against a generated EPUB: title/2 authors/language/publisher/spine, cover served as PNG, range request returned 206 with correct Content-Range, progress round-tripped.
This commit is contained in:
parent
5458b0a28a
commit
5c63932419
10 changed files with 1217 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
82
internal/media/covers.go
Normal file
82
internal/media/covers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
278
internal/media/handlers.go
Normal file
278
internal/media/handlers.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
71
internal/media/media.go
Normal file
71
internal/media/media.go
Normal file
|
|
@ -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
|
||||
}
|
||||
73
internal/modules/ebooks/cover.go
Normal file
73
internal/modules/ebooks/cover.go
Normal file
|
|
@ -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
|
||||
}
|
||||
237
internal/modules/ebooks/epub.go
Normal file
237
internal/modules/ebooks/epub.go
Normal file
|
|
@ -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 <meta name="cover">
|
||||
// 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
|
||||
}
|
||||
141
internal/modules/ebooks/epub_test.go
Normal file
141
internal/modules/ebooks/epub_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package ebooks
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testOPF = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>The Test Book</dc:title>
|
||||
<dc:creator>Ada Lovelace</dc:creator>
|
||||
<dc:creator>Charles Babbage</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:identifier id="pub-id">urn:uuid:abc</dc:identifier>
|
||||
<dc:publisher>Vellum Press</dc:publisher>
|
||||
<dc:date>2026-01-01</dc:date>
|
||||
<meta name="cover" content="cover-img"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="cover-img" href="images/cover.png" media-type="image/png" properties="cover-image"/>
|
||||
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="ch1"/>
|
||||
<itemref idref="ch2"/>
|
||||
</spine>
|
||||
</package>`
|
||||
|
||||
const testContainer = `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`
|
||||
|
||||
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", "<html/>")
|
||||
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 <meta name="cover">.
|
||||
opfMeta := `<package><metadata><title>x</title><meta name="cover" content="c"/></metadata>
|
||||
<manifest><item id="c" href="c.jpg" media-type="image/jpeg"/></manifest><spine/></package>`
|
||||
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 := `<package><metadata><title>x</title></metadata>
|
||||
<manifest><item id="i1" href="images/my-cover.png" media-type="image/png"/></manifest><spine/></package>`
|
||||
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 := `<package><metadata><title>x</title></metadata>
|
||||
<manifest><item id="i1" href="a.png" media-type="image/png"/></manifest><spine/></package>`
|
||||
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
|
||||
}
|
||||
76
internal/modules/ebooks/handlers.go
Normal file
76
internal/modules/ebooks/handlers.go
Normal file
|
|
@ -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})
|
||||
}
|
||||
155
internal/progress/store.go
Normal file
155
internal/progress/store.go
Normal file
|
|
@ -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
|
||||
}
|
||||
100
internal/stream/stream.go
Normal file
100
internal/stream/stream.go
Normal file
|
|
@ -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)))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue