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.
164 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|