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