- 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.
73 lines
1.6 KiB
Go
73 lines
1.6 KiB
Go
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
|
|
}
|