Vellum/internal/media/subtitles.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

226 lines
6.9 KiB
Go

package media
// Subtitle tracks for an item: the list, and one track rendered as WebVTT.
//
// Browsers play WebVTT and nothing else, so every other format is converted on
// the way out. Conversion is cached under the config dir: a player re-requests a
// track as the user seeks, and an embedded track would otherwise re-run ffmpeg
// on every request.
//
// The item is loaded through the catalog chokepoint exactly as handleCover does
// (ADR-005), so a track on an unreadable item answers with the same 404 an
// absent item does.
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/stream"
)
// subtitle is one row of the subtitles table. Path and StreamIndex are the
// source-specific locator and are never sent to clients: a file-backed track
// (sidecar, downloaded) carries a path relative to the library root and
// stream_index -1, an embedded track carries a stream index and an empty path.
type subtitle struct {
ID int64 `json:"id"`
Source string `json:"source"`
Language string `json:"language"`
Label string `json:"label"`
Format string `json:"format"`
Path string `json:"-"`
StreamIndex int `json:"-"`
}
// handleSubtitles lists an item's subtitle tracks.
func (h *Handlers) handleSubtitles(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
subs, err := h.subtitleTracks(r.Context(), it.ID)
if err != nil {
h.fail(w, "list subtitles", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"subtitles": subs})
}
// handleSubtitleTrack renders one track as WebVTT.
func (h *Handlers) handleSubtitleTrack(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
trackID, err := strconv.ParseInt(r.PathValue("trackID"), 10, 64)
if err != nil || trackID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid track id")
return
}
t, err := h.subtitleTrack(r.Context(), it.ID, trackID)
if errors.Is(err, sql.ErrNoRows) {
// A track of another item, or none at all: the same 404, so pairing an
// item id with a track id from elsewhere reveals nothing.
httpx.WriteError(w, http.StatusNotFound, "subtitle not found")
return
}
if err != nil {
h.fail(w, "load subtitle", err)
return
}
vtt, err := h.subtitleVTT(r.Context(), it, t)
if err != nil {
// Only this track fails; the list and every other track still work.
h.fail(w, "convert subtitle", err)
return
}
stream.ServeBytes(w, r, "subtitle.vtt", vtt)
}
// subtitleTracks returns every track for an item, in insertion order.
func (h *Handlers) subtitleTracks(ctx context.Context, itemID int64) ([]subtitle, error) {
rows, err := h.db.QueryContext(ctx, `
SELECT id, source, path, stream_index, format, language, label
FROM subtitles WHERE item_id = ? ORDER BY id`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []subtitle{}
for rows.Next() {
var s subtitle
if err := rows.Scan(&s.ID, &s.Source, &s.Path, &s.StreamIndex,
&s.Format, &s.Language, &s.Label); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// subtitleTrack returns one track, scoped to the item so a track id from
// another item cannot be fetched through it. A miss is sql.ErrNoRows.
func (h *Handlers) subtitleTrack(ctx context.Context, itemID, trackID int64) (subtitle, error) {
var s subtitle
err := h.db.QueryRowContext(ctx, `
SELECT id, source, path, stream_index, format, language, label
FROM subtitles WHERE item_id = ? AND id = ?`, itemID, trackID).
Scan(&s.ID, &s.Source, &s.Path, &s.StreamIndex, &s.Format, &s.Language, &s.Label)
if err != nil {
return subtitle{}, err
}
return s, nil
}
// subtitleVTT returns the track as WebVTT bytes.
//
// .vtt is served as-is. .srt is rewritten in memory — a header and a comma-to-dot
// in each timestamp — so ffmpeg is not a dependency for the one format that does
// not need it. Everything else (embedded streams, .ass/.ssa/.sub) goes through
// ffmpeg, which is the only thing that can demux them.
func (h *Handlers) subtitleVTT(ctx context.Context, it Item, t subtitle) ([]byte, error) {
// A file-backed track sits on disk; an embedded track only exists inside
// the video and has no path of its own.
filePath := filepath.Join(it.RootPath, filepath.FromSlash(t.Path))
if t.Source != "embedded" && strings.EqualFold(t.Format, "vtt") {
return os.ReadFile(filePath)
}
cachePath := filepath.Join(h.cacheDir, "subtitles", fmt.Sprintf("%d-%d.vtt", it.ID, t.ID))
if b, err := os.ReadFile(cachePath); err == nil {
return b, nil
}
var (
b []byte
err error
)
switch {
case t.Source == "embedded":
b, err = ffmpegVTT(ctx, it.AbsPath(), t.StreamIndex)
case strings.EqualFold(t.Format, "srt"):
var raw []byte
if raw, err = os.ReadFile(filePath); err == nil {
b = srtToVTT(raw)
}
default:
b, err = ffmpegVTT(ctx, filePath, -1)
}
if err != nil {
return nil, err
}
// Cache the conversion so a seek does not spawn ffmpeg again.
//
// shortcut: keyed by track id, so a sidecar replaced under the same row
// keeps serving the old conversion until a rescan recreates the row.
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
return nil, err
}
if err := os.WriteFile(cachePath, b, 0o640); err != nil {
return nil, err
}
return b, nil
}
// srtToVTT rewrites SubRip into WebVTT: the WEBVTT header, and a dot where a
// timestamp used a comma. Only lines carrying "-->" are touched, so a comma in
// the subtitle text itself survives.
func srtToVTT(srt []byte) []byte {
var b bytes.Buffer
b.WriteString("WEBVTT\n\n")
for _, line := range strings.Split(string(srt), "\n") {
if strings.Contains(line, "-->") {
line = strings.ReplaceAll(line, ",", ".")
}
b.WriteString(line)
b.WriteByte('\n')
}
return b.Bytes()
}
// ffmpegVTT extracts a subtitle stream as WebVTT on stdout.
//
// streamIndex is the embedded subtitle stream to map, or -1 to let ffmpeg pick
// the file's only subtitle stream.
func ffmpegVTT(ctx context.Context, input string, streamIndex int) ([]byte, error) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, fmt.Errorf("media: ffmpeg not available: %w", err)
}
args := []string{"-v", "error", "-i", input}
if streamIndex >= 0 {
args = append(args, "-map", fmt.Sprintf("0:s:%d", streamIndex))
}
args = append(args, "-f", "webvtt", "-")
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, ffmpeg, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("media: ffmpeg subtitle %s: %s", input, msg)
}
return stdout.Bytes(), nil
}