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 }