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.
This commit is contained in:
mithral 2026-10-12 11:42:51 -04:00
parent c247539ac0
commit 6201535dd7
6 changed files with 746 additions and 8 deletions

View file

@ -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}/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}/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}/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("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("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress))
mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent)) mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent))

226
internal/media/subtitles.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -125,13 +125,24 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
return res, err return res, err
} }
found, errs := walkRoot(lib.RootPath, exts) found, sidecars, errs := walkRoot(lib.RootPath, exts)
for _, e := range errs { for _, e := range errs {
res.addError(e) res.addError(e)
} }
groups := buildGroups(lib, found) 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() now := core.Now()
seenFiles := make(map[string]bool, len(found)) 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++ res.Scanned++
seenFiles[c.relPath] = true 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] prev, exists := knownFiles[c.relPath]
if exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime { if exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime {
res.Unchanged++ res.Unchanged++
@ -275,6 +292,14 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
report() 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() res.Duration = time.Since(start).Milliseconds()
log.Info("scan complete", log.Info("scan complete",
"library", lib.Name, "kind", lib.Kind, "grouping", library.GroupModeFor(lib.Kind), "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 return groups
} }
// walkRoot collects supported files under root, keyed by slash-separated // sidecarExts are the subtitle file types a video may carry beside it. They are
// path relative to root. // not library extensions: a subtitle never becomes an item, it is only ever a
func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) { // 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) out := make(map[string]candidate)
subs := make(map[string]candidate)
var errs []string var errs []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { 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)) ext := strings.ToLower(filepath.Ext(name))
if !exts[ext] { isMedia := exts[ext]
if !isMedia && !sidecarExts[ext] {
return nil 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 { if st, ok := info.Sys().(*syscall.Stat_t); ok {
c.inode = int64(st.Ino) 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 return nil
}) })
if err != nil { if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", root, err)) 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) { 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 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 // probeBatch probes every new or changed video/audio file in one batch and
// returns the facts keyed by relative path. // returns the facts keyed by relative path.
// //

View file

@ -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 // 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 // must not try to parse episode numbers, and a TV library must not scatter its
// episodes across the root. // episodes across the root.

View file

@ -49,6 +49,9 @@ var contentTypes = map[string]string{
".webm": "video/webm", ".webm": "video/webm",
".mov": "video/quicktime", ".mov": "video/quicktime",
".avi": "video/x-msvideo", ".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). // Images (comic pages, covers, photos).
".jpg": "image/jpeg", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",