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:
parent
c247539ac0
commit
6201535dd7
6 changed files with 746 additions and 8 deletions
|
|
@ -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
226
internal/media/subtitles.go
Normal 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
|
||||||
|
}
|
||||||
164
internal/media/subtitles_test.go
Normal file
164
internal/media/subtitles_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
// 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
|
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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue