- 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.
100 lines
3 KiB
Go
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)))
|
|
}
|