diff --git a/internal/media/handlers.go b/internal/media/handlers.go index 98c5494..f6317b0 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -47,6 +47,8 @@ func (h *Handlers) Register(mux *http.ServeMux) { mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile)) mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID)) mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover)) + mux.Handle("GET /api/v1/items/{id}/subtitles", h.auth.Require(h.handleSubtitles)) + mux.Handle("GET /api/v1/items/{id}/subtitles/{trackID}", h.auth.Require(h.handleSubtitleTrack)) mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress)) mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress)) mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent)) diff --git a/internal/media/subtitles.go b/internal/media/subtitles.go new file mode 100644 index 0000000..a375e81 --- /dev/null +++ b/internal/media/subtitles.go @@ -0,0 +1,226 @@ +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 +} diff --git a/internal/media/subtitles_test.go b/internal/media/subtitles_test.go new file mode 100644 index 0000000..748773c --- /dev/null +++ b/internal/media/subtitles_test.go @@ -0,0 +1,164 @@ +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) + } +} diff --git a/internal/scanner/scan.go b/internal/scanner/scan.go index 8a85713..0357559 100644 --- a/internal/scanner/scan.go +++ b/internal/scanner/scan.go @@ -125,13 +125,24 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger return res, err } - found, errs := walkRoot(lib.RootPath, exts) + found, sidecars, errs := walkRoot(lib.RootPath, exts) for _, e := range errs { res.addError(e) } groups := buildGroups(lib, found) + // Sidecar subtitle files, bucketed by the directory they sit in so a video + // can find its own without scanning every subtitle. seenSidecars records + // which ones a video actually claimed; anything else is deleted below, + // because the subtitles table is a cache of what is on disk. + sidecarsByDir := make(map[string][]candidate) + for _, s := range sidecars { + dir := path.Dir(s.relPath) + sidecarsByDir[dir] = append(sidecarsByDir[dir], s) + } + seenSidecars := make(map[string]bool) + now := core.Now() seenFiles := make(map[string]bool, len(found)) @@ -182,6 +193,12 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger res.Scanned++ seenFiles[c.relPath] = true + // Sidecars are keyed to the video beside them, not to the + // media_files row, so this runs even for an unchanged video: + // a new subtitle beside a settled file must still be picked + // up, and its row must not be pruned as vanished. + upsertSidecars(ctx, tx, itemID, c, sidecarsByDir[path.Dir(c.relPath)], seenSidecars, &res) + prev, exists := knownFiles[c.relPath] if exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime { res.Unchanged++ @@ -275,6 +292,14 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger report() } + // Sidecar subtitle rows are a cache of what is on disk: a row whose file is + // gone is deleted, not flagged missing. This is scoped to the library + // through item_id, and runs every scan so a library that lost all its + // subtitles still ends up with none. + if err := reconcileSidecars(ctx, db, lib.ID, seenSidecars, &res); err != nil { + return res, err + } + res.Duration = time.Since(start).Milliseconds() log.Info("scan complete", "library", lib.Name, "kind", lib.Kind, "grouping", library.GroupModeFor(lib.Kind), @@ -546,10 +571,19 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g return groups } -// walkRoot collects supported files under root, keyed by slash-separated -// path relative to root. -func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) { +// sidecarExts are the subtitle file types a video may carry beside it. They are +// not library extensions: a subtitle never becomes an item, it is only ever a +// track hanging off the video it sits next to. +var sidecarExts = map[string]bool{ + ".srt": true, ".ass": true, ".ssa": true, ".vtt": true, ".sub": true, +} + +// walkRoot collects supported files under root, keyed by slash-separated path +// relative to root. Subtitle sidecars are returned separately: they are +// discovered here, with everything else, but are not items. +func walkRoot(root string, exts map[string]bool) (map[string]candidate, map[string]candidate, []string) { out := make(map[string]candidate) + subs := make(map[string]candidate) var errs []string err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { @@ -573,7 +607,8 @@ func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string } ext := strings.ToLower(filepath.Ext(name)) - if !exts[ext] { + isMedia := exts[ext] + if !isMedia && !sidecarExts[ext] { return nil } @@ -601,13 +636,19 @@ func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string if st, ok := info.Sys().(*syscall.Stat_t); ok { c.inode = int64(st.Ino) } - out[c.relPath] = c + // A media extension wins: if a kind ever owns one of these, it is a + // media file first and a subtitle only by coincidence. + if isMedia { + out[c.relPath] = c + } else { + subs[c.relPath] = c + } return nil }) if err != nil { errs = append(errs, fmt.Sprintf("%s: %v", root, err)) } - return out, errs + return out, subs, errs } func loadKnownFiles(ctx context.Context, db *sql.DB, libraryID int64) (map[string]knownFile, error) { @@ -777,6 +818,116 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info return nil } +// upsertSidecars matches the subtitle files in one directory to the media file +// they sit beside, and writes a row for each. A sidecar belongs to a video when +// its name is the video's base name with an optional language segment between: +// "Movie.srt" and "Movie.en.srt" both belong to "Movie.mkv", the latter with +// language "en". The language is the whole middle segment, so "pt-BR" survives +// intact. +// +// The match is a prefix guarded by the dot, not a split, because a release name +// may itself contain dots: for "The.Matrix.1999.1080p.mkv" the sidecar is +// "The.Matrix.1999.1080p.en.srt" and only the trailing segment is a language. +func upsertSidecars(ctx context.Context, tx *sql.Tx, itemID int64, media candidate, sidecars []candidate, seen map[string]bool, res *ScanResult) { + if len(sidecars) == 0 { + return + } + base := strings.TrimSuffix(path.Base(media.relPath), path.Ext(media.relPath)) + + for _, s := range sidecars { + stem := strings.TrimSuffix(path.Base(s.relPath), path.Ext(s.relPath)) + var lang string + switch { + case stem == base: + lang = "" + case strings.HasPrefix(stem, base+"."): + lang = stem[len(base)+1:] + default: + continue + } + seen[s.relPath] = true + if err := upsertSidecar(ctx, tx, itemID, s, lang); err != nil { + res.addError(fmt.Sprintf("%s: %v", s.relPath, err)) + } + } +} + +// upsertSidecar writes one sidecar track. The conflict target is the table's +// UNIQUE (item_id, source, path, stream_index), so a rescan refreshes the row +// instead of adding a second one. stream_index is -1, the file-backed value. +func upsertSidecar(ctx context.Context, tx *sql.Tx, itemID int64, s candidate, lang string) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO subtitles (item_id, source, path, stream_index, format, language, label) + VALUES (?, 'sidecar', ?, -1, ?, ?, ?) + ON CONFLICT (item_id, source, path, stream_index) + DO UPDATE SET format = excluded.format, language = excluded.language, + label = excluded.label`, + itemID, s.relPath, s.ext, lang, path.Base(s.relPath)) + if err != nil { + return fmt.Errorf("upsert sidecar: %w", err) + } + return nil +} + +// reconcileSidecars deletes sidecar rows whose file is no longer on disk. It +// reads the library's existing rows and removes only the ones no video claimed +// this pass, so the subtitles table stays exactly a cache of what is beside the +// media. It never touches embedded or downloaded tracks. +func reconcileSidecars(ctx context.Context, db *sql.DB, libraryID int64, seen map[string]bool, res *ScanResult) error { + rows, err := db.QueryContext(ctx, ` + SELECT s.path FROM subtitles s + JOIN media_items i ON i.id = s.item_id + WHERE i.library_id = ? AND s.source = 'sidecar'`, libraryID) + if err != nil { + return fmt.Errorf("load sidecars: %w", err) + } + var stale []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + rows.Close() + return err + } + if !seen[p] { + stale = append(stale, p) + } + } + if err := rows.Close(); err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + if len(stale) == 0 { + return nil + } + sort.Strings(stale) + + for start := 0; start < len(stale); start += scanBatchSize { + end := start + scanBatchSize + if end > len(stale) { + end = len(stale) + } + batch := stale[start:end] + err := commitBatch(ctx, db, func(tx *sql.Tx) error { + for _, p := range batch { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM subtitles + WHERE source = 'sidecar' AND path = ? + AND item_id IN (SELECT id FROM media_items WHERE library_id = ?)`, + p, libraryID); err != nil { + res.addError(fmt.Sprintf("%s: %v", p, err)) + } + } + return nil + }) + if err != nil { + return err + } + } + return nil +} + // probeBatch probes every new or changed video/audio file in one batch and // returns the facts keyed by relative path. // diff --git a/internal/scanner/scan_test.go b/internal/scanner/scan_test.go index 42ce4d5..397a322 100644 --- a/internal/scanner/scan_test.go +++ b/internal/scanner/scan_test.go @@ -794,7 +794,199 @@ func TestParseEpisodePatterns(t *testing.T) { } } -// The same tree must behave differently depending on the library's declared +// subtitle is one subtitles row as the tests want to see it. +type subtitle struct { + source string + path string + format string + lang string + label string + stream int +} + +// scanSubtitles returns every subtitle track in a library, ordered by path. +func scanSubtitles(t *testing.T, db *sql.DB, libraryID int64) []subtitle { + t.Helper() + rows, err := db.Query(` + SELECT s.source, s.path, s.format, s.language, s.label, s.stream_index + FROM subtitles s JOIN media_items i ON i.id = s.item_id + WHERE i.library_id = ? ORDER BY s.path`, libraryID) + if err != nil { + t.Fatalf("query subtitles: %v", err) + } + defer rows.Close() + + var out []subtitle + for rows.Next() { + var s subtitle + if err := rows.Scan(&s.source, &s.path, &s.format, &s.lang, &s.label, &s.stream); err != nil { + t.Fatalf("scan subtitle: %v", err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + return out +} + +// A .srt beside a video is discovered as a sidecar track, with the fields the +// table defines: the path relative to the root, the format from the extension, +// no language, and stream_index -1. +func TestScanSidecarSubtitle(t *testing.T) { + db := testDB(t) + ctx := context.Background() + root := t.TempDir() + + writeFile(t, filepath.Join(root, "Movie.mkv"), "video") + writeFile(t, filepath.Join(root, "Movie.srt"), "subs") + + lib, err := library.Create(ctx, db, "Movies", "movies", root) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("Scan: %v", err) + } + + got := scanSubtitles(t, db, lib.ID) + if len(got) != 1 { + t.Fatalf("got %d subtitle rows, want 1: %+v", len(got), got) + } + want := subtitle{source: "sidecar", path: "Movie.srt", format: "srt", lang: "", label: "Movie.srt", stream: -1} + if got[0] != want { + t.Errorf("subtitle = %+v, want %+v", got[0], want) + } +} + +// The middle segment of "Movie.en.srt" / "Movie.pt-BR.srt" is the language, and +// a release name full of dots must not be mistaken for one. +func TestScanSidecarSubtitleLanguage(t *testing.T) { + db := testDB(t) + ctx := context.Background() + root := t.TempDir() + + writeFile(t, filepath.Join(root, "Movie.mkv"), "video") + writeFile(t, filepath.Join(root, "Movie.en.srt"), "en") + writeFile(t, filepath.Join(root, "Movie.pt-BR.srt"), "pt") + writeFile(t, filepath.Join(root, "The.Matrix.1999.1080p.mkv"), "video2") + writeFile(t, filepath.Join(root, "The.Matrix.1999.1080p.fr.ass"), "fr") + + lib, err := library.Create(ctx, db, "Movies", "movies", root) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("Scan: %v", err) + } + + want := []subtitle{ + {source: "sidecar", path: "Movie.en.srt", format: "srt", lang: "en", label: "Movie.en.srt", stream: -1}, + {source: "sidecar", path: "Movie.pt-BR.srt", format: "srt", lang: "pt-BR", label: "Movie.pt-BR.srt", stream: -1}, + {source: "sidecar", path: "The.Matrix.1999.1080p.fr.ass", format: "ass", lang: "fr", label: "The.Matrix.1999.1080p.fr.ass", stream: -1}, + } + got := scanSubtitles(t, db, lib.ID) + if len(got) != len(want) { + t.Fatalf("got %d subtitle rows, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("subtitle %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +// A rescan must refresh the sidecar row, not add a second one for the same +// file: the table's UNIQUE constraint is the dedupe, not a lookup. +func TestScanSidecarRescanDoesNotDuplicate(t *testing.T) { + db := testDB(t) + ctx := context.Background() + root := t.TempDir() + + writeFile(t, filepath.Join(root, "Movie.mkv"), "video") + writeFile(t, filepath.Join(root, "Movie.en.srt"), "subs") + + lib, err := library.Create(ctx, db, "Movies", "movies", root) + if err != nil { + t.Fatalf("Create: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("Scan %d: %v", i, err) + } + } + + if got := scanSubtitles(t, db, lib.ID); len(got) != 1 { + t.Fatalf("got %d subtitle rows after rescans, want 1: %+v", len(got), got) + } +} + +// Subtitles are a cache of what is on disk: a sidecar that disappears has its +// row deleted, while the video beside it is untouched and still not missing. +func TestScanSidecarRemovalDeletesRow(t *testing.T) { + db := testDB(t) + ctx := context.Background() + root := t.TempDir() + + writeFile(t, filepath.Join(root, "Movie.mkv"), "video") + writeFile(t, filepath.Join(root, "Movie.srt"), "subs") + + lib, err := library.Create(ctx, db, "Movies", "movies", root) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("first Scan: %v", err) + } + if got := scanSubtitles(t, db, lib.ID); len(got) != 1 { + t.Fatalf("first scan produced %d subtitle rows, want 1", len(got)) + } + + if err := os.Remove(filepath.Join(root, "Movie.srt")); err != nil { + t.Fatal(err) + } + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("second Scan: %v", err) + } + + if got := scanSubtitles(t, db, lib.ID); len(got) != 0 { + t.Errorf("vanished sidecar left %d rows, want 0: %+v", len(got), got) + } + + // The video is still there and was never flagged missing. + var missing int + if err := db.QueryRowContext(ctx, + `SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Movie.mkv").Scan(&missing); err != nil { + t.Fatalf("video item is gone: %v", err) + } + if missing != 0 { + t.Errorf("video missing = %d, want 0", missing) + } +} + +// A sidecar never becomes an item of its own, and an unsupported file beside a +// video is ignored entirely. +func TestScanSidecarIsNotAnItem(t *testing.T) { + db := testDB(t) + ctx := context.Background() + root := t.TempDir() + + writeFile(t, filepath.Join(root, "Movie.mkv"), "video") + writeFile(t, filepath.Join(root, "Movie.srt"), "subs") + + lib, err := library.Create(ctx, db, "Movies", "movies", root) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("Scan: %v", err) + } + + if n, _ := library.CountItems(ctx, db, lib.ID); n != 1 { + t.Errorf("item count = %d, want 1 (the video only)", n) + } +} + // grouping. That is the whole point of separating the video kinds: a film shelf // must not try to parse episode numbers, and a TV library must not scatter its // episodes across the root. diff --git a/internal/stream/stream.go b/internal/stream/stream.go index 620a90e..02e2bec 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -49,6 +49,9 @@ var contentTypes = map[string]string{ ".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",