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.
109 lines
3.4 KiB
Go
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)))
|
|
}
|