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

164 lines
5.9 KiB
Go

package media
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
)
// subtitleFixture builds a library rooted at a real temp dir, so a sidecar
// subtitle can be written to disk and read back through the route, plus a second
// item in a library the caller may not read.
type subtitleFixture struct {
h *Handlers
db *sql.DB
root string
user int64
item int64
forbidden int64
}
func newSubtitleFixture(t *testing.T) subtitleFixture {
t.Helper()
db := testDB(t)
root := t.TempDir()
lib := newLibrary(t, db, "Video", "video", root)
other := newLibrary(t, db, "Other", "video", t.TempDir())
f := subtitleFixture{db: db, root: root}
f.user = newUser(t, db, "watcher")
grantRead(t, db, f.user, lib)
const at = "2026-01-01T00:00:00.000Z"
f.item = newItem(t, db, lib, "video", "movie.mkv", "Movie", "movie", false, at)
f.forbidden = newItem(t, db, other, "video", "secret.mkv", "Secret", "secret", false, at)
f.h = mediaHandlers(t, db)
return f
}
// addTrack writes a sidecar file and the subtitles row that points at it.
func (f subtitleFixture) addTrack(t *testing.T, name, content, format, lang string) int64 {
t.Helper()
if err := os.WriteFile(filepath.Join(f.root, name), []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return mustQueryID(t, f.db, `
INSERT INTO subtitles (item_id, source, path, stream_index, format, language, label)
VALUES (?, 'sidecar', ?, -1, ?, ?, ?) RETURNING id`,
f.item, name, format, lang, name)
}
func listSubtitleTracks(t *testing.T, h *Handlers, u auth.User, itemID int64) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/subtitles", nil)
req.SetPathValue("id", itoa(itemID))
rec := httptest.NewRecorder()
h.handleSubtitles(rec, req, u)
return rec
}
func getSubtitleTrack(t *testing.T, h *Handlers, u auth.User, itemID, trackID int64) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/subtitles/1", nil)
req.SetPathValue("id", itoa(itemID))
req.SetPathValue("trackID", itoa(trackID))
rec := httptest.NewRecorder()
h.handleSubtitleTrack(rec, req, u)
return rec
}
// TestSubtitleSRTConvertsToVTT pins the SubRip rewrite: the WebVTT header, a dot
// in every timestamp, and the subtitle text left alone.
func TestSubtitleSRTConvertsToVTT(t *testing.T) {
f := newSubtitleFixture(t)
srt := "1\n00:00:01,000 --> 00:00:04,000\nHello, world\n\n2\n00:00:05,500 --> 00:00:06,000\nBye\n"
id := f.addTrack(t, "movie.en.srt", srt, "srt", "en")
rec := getSubtitleTrack(t, f.h, auth.User{ID: f.user}, f.item, id)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "text/vtt; charset=utf-8" {
t.Errorf("content-type = %q, want text/vtt; charset=utf-8", ct)
}
body := rec.Body.String()
if !strings.HasPrefix(body, "WEBVTT\n\n") {
t.Errorf("body does not start with WEBVTT header: %q", body)
}
if !strings.Contains(body, "00:00:01.000 --> 00:00:04.000") ||
!strings.Contains(body, "00:00:05.500 --> 00:00:06.000") {
t.Errorf("timestamps not dot-separated: %q", body)
}
if strings.Contains(body, ",000") || strings.Contains(body, ",500") {
t.Errorf("a comma survived in a timestamp: %q", body)
}
if !strings.Contains(body, "Hello, world") {
t.Errorf("a comma in the subtitle text was rewritten: %q", body)
}
}
// TestSubtitleVTTPassesThrough pins that an already-WebVTT sidecar is served
// byte for byte, with no header prepended and no timestamp touched.
func TestSubtitleVTTPassesThrough(t *testing.T) {
f := newSubtitleFixture(t)
vtt := "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHi there\n"
id := f.addTrack(t, "movie.en.vtt", vtt, "vtt", "en")
rec := getSubtitleTrack(t, f.h, auth.User{ID: f.user}, f.item, id)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != vtt {
t.Errorf("body = %q, want unchanged %q", got, vtt)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/vtt; charset=utf-8" {
t.Errorf("content-type = %q, want text/vtt; charset=utf-8", ct)
}
}
// TestSubtitleRoutesAreGrantScoped pins the access chokepoint on both routes: an
// item in a library the caller cannot read must answer with the same 404, byte
// for byte, as an item that does not exist.
func TestSubtitleRoutesAreGrantScoped(t *testing.T) {
f := newSubtitleFixture(t)
u := auth.User{ID: f.user}
// The readable item lists, empty, with a 200.
if rec := listSubtitleTracks(t, f.h, u, f.item); rec.Code != http.StatusOK {
t.Errorf("readable list status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
forbidden := listSubtitleTracks(t, f.h, u, f.forbidden)
absent := listSubtitleTracks(t, f.h, u, 999999)
if forbidden.Code != http.StatusNotFound || absent.Code != http.StatusNotFound {
t.Fatalf("list statuses = %d/%d, want 404/404", forbidden.Code, absent.Code)
}
if forbidden.Body.String() != absent.Body.String() {
t.Errorf("forbidden body %q != absent body %q", forbidden.Body.String(), absent.Body.String())
}
// The track route leaks nothing either: the item is rejected before the
// track id is ever looked at.
fb := getSubtitleTrack(t, f.h, u, f.forbidden, 1)
ab := getSubtitleTrack(t, f.h, u, 999999, 1)
if fb.Code != http.StatusNotFound || ab.Code != http.StatusNotFound {
t.Fatalf("track statuses = %d/%d, want 404/404", fb.Code, ab.Code)
}
if fb.Body.String() != ab.Body.String() {
t.Errorf("track forbidden body %q != absent body %q", fb.Body.String(), ab.Body.String())
}
// A user with no grants at all gets the same 404 on the readable item.
none := listSubtitleTracks(t, f.h, auth.User{ID: newUser(t, f.db, "nobody")}, f.item)
if none.Code != http.StatusNotFound {
t.Errorf("no-grant list status = %d, want 404", none.Code)
}
}