Vellum/internal/stream/stream.go
mithral 6201535dd7 Discover and serve subtitle tracks
Two halves of the same feature: the scanner finds tracks, the media layer
serves them as WebVTT.

Discovery walks each directory for .srt/.ass/.ssa/.vtt/.sub beside a video and
matches them by base name, including the language form "Movie.en.srt" and
"Movie.pt-BR.srt". A sidecar is never itself an item. Matching is a dot-guarded
prefix on the real video base name, so a dotted release name like
"The.Matrix.1999.1080p.en.srt" does not read the year as a language.

Serving adds two routes inside the existing item family, both grant-checked
through catalog.Store (ADR-005) so forbidden and absent are the same 404.
Browsers play WebVTT and nothing else, so .srt is converted (header plus the
comma-to-dot in timestamps), .vtt passes through, and embedded streams and
.ass/.ssa go through ffmpeg. Conversions are cached under ConfigDir, because
converting per request would spawn an ffmpeg on every seek. A track that fails
to convert fails alone - the video and the other tracks are unaffected.

Subtitle rows are a cache of what is on disk, so a vanished sidecar has its row
deleted. That is the opposite of a vanished video, which is marked instead
(ADR-003) because progress hangs off it and cannot be regenerated.

Verified red-green: disabling the sidecar call fails all four scanner tests,
and removing the VTT header fails the conversion test.

Known limits: a "Movie.forced.en.srt" records "forced.en" rather than "en",
and the conversion cache is keyed by track id, so a sidecar replaced under an
unchanged row serves the stale conversion until a rescan recreates the row.
2026-10-12 11:42:51 -04:00

109 lines
3.4 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.
//
// This table and the library extension registry (internal/library/registry.go)
// must agree: an extension in the video registry with no MIME entry here is
// served as application/octet-stream, which browsers download instead of
// playing. .avi was in the registry but missing here.
".mp4": "video/mp4",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
// Subtitles. The server converts every other format to this one, because
// it is the only subtitle format browsers play.
".vtt": "text/vtt; charset=utf-8",
// 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)))
}