Vellum/internal/stream/stream.go
mithral 5c63932419 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.
2026-10-09 16:36:27 -04:00

100 lines
3 KiB
Go

// 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)))
}