Wave 3: search depth, transfer size, and the Home screen's tests

Authors are searchable (internal/search, migrations/0009)
- The FTS index covered only title and path, so typing an artist or author
  found nothing even though people/item_people already held the credits.
- Rather than a second index, the names are denormalised into a real
  media_items.authors column, so the existing external-content FTS and its
  triggers keep doing the work; item_people/people triggers recompute it, and
  the media_items UPDATE trigger re-indexes automatically.
- Verified: searching "daft punk" now returns Random Access Memories, and so
  does the partial "punk".
- The agent found and worked around a real FTS5 external-content bug: on an
  UPGRADE the backfill UPDATE fires the delete trigger for rowids not yet in
  the fresh index, raising "database disk image is malformed". A rebuild before
  the backfill fixes it. This would have broken existing databases while a
  fresh install looked fine.

gzip for the SPA (internal/httpapi/static.go)
- 456 KB of JS and 31 KB of CSS were served uncompressed on every cold load.
- Scoped to the embedded SPA assets only, never API or media: compressing a
  Range response would break seeking, and wrapping every response would undo
  Wave 2's Flusher/ReaderFrom forwarding.
- Compresses only when the client accepts gzip, the response is a compressible
  type, there is no Range header, and nothing already set Content-Encoding.
  Sets Vary: Accept-Encoding and drops the now-wrong Content-Length.
- Verified by sha256 round-trip (identity vs gzip-decompressed are identical),
  and by confirming a Range request still returns 206 with no Content-Encoding.
  JS 351,835 -> 108,794 bytes (69% smaller); CSS 80% smaller.

epub.js is code-split (Reader.svelte)
- It was a static import, so ~350 KB was downloaded by everyone on first load
  even though most never open an ebook. Now loaded on demand.
- Main bundle 458 KB -> 112 KB (gzipped 149 KB -> 39.5 KB).
- Verified at runtime, which pnpm check cannot: only the main bundle loads
  initially, the epubjs chunk is fetched when a book is opened, and the reader
  still renders.

Home screen tests (internal/media)
- Coverage 23.4% -> 50.2%. (The 17.9% quoted in the brief was a stale reading;
  the agent measured the real baseline by moving its own files aside.)
- Covers the five shelves, counts, Explore's invariants (visibility, no
  duplicates, every kind represented, varies across calls, wrap-around),
  the list offset/limit/total contract, MostViewed ordering, and — most
  importantly — grant scoping, which is the security chokepoint.

Fixed a bug the tests found: Suggestions seeded from revoked libraries
- topEngagement selected the user's most recent progress row with no library
  filter, so progress from a library whose grant had been revoked still steered
  the home screen — a visible item was labelled "More books" based on content
  the user could no longer read. Returned items were always correctly scoped, so
  this was an information-flow gap rather than a content leak.
- Now scoped to visible libraries, and the test that pinned the old behaviour
  flipped to assert the correct reason.

gofmt/vet clean, 14 packages pass, media tests run 3x for flakiness.
This commit is contained in:
mithral 2026-10-11 13:46:18 -04:00
parent cf34d8b11a
commit 09474b0e08
10 changed files with 1817 additions and 10 deletions

View file

@ -1,9 +1,14 @@
package httpapi package httpapi
import ( import (
"bufio"
"compress/gzip"
"io"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"path" "path"
"strconv"
"strings" "strings"
"github.com/mithral/vellum/web" "github.com/mithral/vellum/web"
@ -30,23 +35,31 @@ func (s *Server) mountWeb() {
} }
files := http.FileServer(http.FS(sub)) files := http.FileServer(http.FS(sub))
// Only the embedded static assets are wrapped for compression. API
// responses and media are served by other handlers and are never touched.
serveFiles := compressStatic(files.ServeHTTP)
serveShell := compressStatic(func(w http.ResponseWriter, r *http.Request) {
serveIndex(w, index)
})
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if isAPIPath(r.URL.Path) { if isAPIPath(r.URL.Path) {
s.handleNotFound(w, r) s.handleNotFound(w, r)
return return
} }
if r.URL.Path == "/" || r.URL.Path == "" { if r.URL.Path == "/" || r.URL.Path == "" {
serveIndex(w, index) serveShell(w, r)
return return
} }
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if f, err := sub.Open(name); err == nil { if f, err := sub.Open(name); err == nil {
_ = f.Close() _ = f.Close()
files.ServeHTTP(w, r) serveFiles(w, r)
return return
} }
// SPA route: hand back the shell and let the client router decide. // SPA route: hand back the shell and let the client router decide.
serveIndex(w, index) serveShell(w, r)
}) })
} }
@ -59,3 +72,180 @@ func serveIndex(w http.ResponseWriter, index []byte) {
func isAPIPath(p string) bool { func isAPIPath(p string) bool {
return p == "/healthz" || strings.HasPrefix(p, "/api/") return p == "/healthz" || strings.HasPrefix(p, "/api/")
} }
// ── Static-asset gzip ──────────────────────────────────────────────────
//
// The built SPA ships uncompressed: the JS bundle is ~456 KB and the CSS
// ~31 KB on every cold load. compressStatic wraps only the embedded static
// handlers with gzip negotiation; API responses and media are never wrapped.
//
// The narrow scope is deliberate. (a) Media is already compressed. (b) A Range
// response is used for seeking, and re-encoding it would invalidate the byte
// offsets. (c) server.go's statusRecorder was just fixed to forward
// http.Flusher and io.ReaderFrom for streaming; wrapping every response in a
// gzip writer would force all bodies through a compressor and undo that.
// Restricting the wrapper to the static file handler avoids all three.
// compressStatic gzips a static handler's response when the client accepts
// gzip and the response is a compressible text type. Close is called after the
// handler returns so the gzip trailer is always written.
func compressStatic(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gz := newGzipResponseWriter(w, r)
next(gz, r)
_ = gz.Close()
}
}
// compressibleType reports whether a Content-Type should be gzip-encoded. Only
// text-like types are listed; images, fonts, wasm and other media are already
// compressed, so re-encoding them wastes CPU for no gain.
func compressibleType(contentType string) bool {
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i] // drop "charset=utf-8" and friends
}
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "text/html",
"text/css",
"application/javascript",
"text/javascript",
"application/json",
"image/svg+xml",
"application/manifest+json":
return true
}
return false
}
// acceptsGzip reports whether the client's Accept-Encoding allows gzip. An
// explicit "gzip;q=0" is a refusal, so it does not count.
func acceptsGzip(header string) bool {
for _, part := range strings.Split(header, ",") {
token, params, _ := strings.Cut(part, ";")
if !strings.EqualFold(strings.TrimSpace(token), "gzip") {
continue
}
for _, p := range strings.Split(params, ";") {
if v, ok := strings.CutPrefix(strings.TrimSpace(p), "q="); ok {
if q, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && q == 0 {
return false
}
}
}
return true
}
return false
}
// gzipResponseWriter wraps a static response so its body can be gzip-encoded.
//
// Like statusRecorder it forwards Flush, Hijack and ReadFrom. While the body is
// being compressed those forwards must route through the compressor (or flush
// it first), or raw bytes would be written under a gzip Content-Encoding.
type gzipResponseWriter struct {
http.ResponseWriter
eligible bool
compress bool
wroteHeader bool
gz *gzip.Writer
}
func newGzipResponseWriter(w http.ResponseWriter, r *http.Request) *gzipResponseWriter {
return &gzipResponseWriter{
ResponseWriter: w,
// A Range request must stay identity so its byte offsets remain valid.
eligible: acceptsGzip(r.Header.Get("Accept-Encoding")) && r.Header.Get("Range") == "",
}
}
// WriteHeader decides compression once the handler has set Content-Type. The
// decision has to happen before the status line is committed.
func (g *gzipResponseWriter) WriteHeader(code int) {
if g.wroteHeader {
return
}
g.wroteHeader = true
h := g.Header()
if g.eligible &&
code == http.StatusOK &&
h.Get("Content-Encoding") == "" &&
h.Get("Content-Length") != "0" &&
compressibleType(h.Get("Content-Type")) {
g.compress = true
h.Set("Content-Encoding", "gzip")
h.Add("Vary", "Accept-Encoding")
// The compressed length is not known until the body is written, and
// the handler's original Content-Length is now wrong.
h.Del("Content-Length")
}
g.ResponseWriter.WriteHeader(code)
}
// writer lazily creates the gzip stream on the first body byte. A HEAD request
// (or a bodyless response) never writes, so no empty gzip stream is emitted and
// net/http has nothing from which to fabricate a misleading Content-Length.
func (g *gzipResponseWriter) writer() *gzip.Writer {
if g.gz == nil {
g.gz = gzip.NewWriter(g.ResponseWriter)
}
return g.gz
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
if !g.wroteHeader {
g.WriteHeader(http.StatusOK)
}
if g.compress {
return g.writer().Write(b)
}
return g.ResponseWriter.Write(b)
}
// Flush forwards to the underlying http.Flusher, after flushing any buffered
// compressed bytes so the client sees a complete gzip block.
func (g *gzipResponseWriter) Flush() {
if !g.wroteHeader {
g.WriteHeader(http.StatusOK)
}
if g.compress && g.gz != nil {
_ = g.gz.Flush()
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Hijack forwards to the underlying http.Hijacker for protocols that take over
// the connection.
func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := g.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// ReadFrom forwards to the underlying io.ReaderFrom so io.Copy keeps the
// sendfile fast path. When compressing, the bytes must pass through the gzip
// writer instead; the returned count is the source byte count.
func (g *gzipResponseWriter) ReadFrom(src io.Reader) (int64, error) {
if !g.wroteHeader {
g.WriteHeader(http.StatusOK)
}
if g.compress {
return io.Copy(g.writer(), src)
}
if rf, ok := g.ResponseWriter.(io.ReaderFrom); ok {
return rf.ReadFrom(src)
}
return io.Copy(g.ResponseWriter, src)
}
// Close flushes the gzip trailer. It must run after the static handler returns,
// or the body will be missing its checksum and the client will reject it.
func (g *gzipResponseWriter) Close() error {
if g.compress && g.gz != nil {
return g.gz.Close()
}
return nil
}

View file

@ -0,0 +1,335 @@
package httpapi
import (
"bufio"
"bytes"
"compress/gzip"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mithral/vellum/web"
)
// findAsset returns the path (relative to the embedded dist root, no leading
// slash) of the first embedded file ending in suffix. Assets are content-hashed
// by the build, so tests must discover them rather than hardcode a name.
func findAsset(t *testing.T, suffix string) string {
t.Helper()
sub, err := fs.Sub(web.Dist, "dist")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
var found string
err = fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if found == "" && strings.HasSuffix(p, suffix) {
found = p
}
return nil
})
if err != nil {
t.Fatalf("walk dist: %v", err)
}
if found == "" {
t.Fatalf("no embedded asset ending in %q", suffix)
}
return found
}
func readAsset(t *testing.T, name string) []byte {
t.Helper()
sub, err := fs.Sub(web.Dist, "dist")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
b, err := fs.ReadFile(sub, name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return b
}
func gunzip(t *testing.T, body []byte) []byte {
t.Helper()
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
defer zr.Close()
out, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("read gzip body: %v", err)
}
return out
}
// TestStaticGzipCompressesJS proves a gzip-capable request for a .js asset is
// compressed, carries no stale Content-Length, and round-trips to the original.
func TestStaticGzipCompressesJS(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".js")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
req.Header.Set("Accept-Encoding", "gzip")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") {
t.Errorf("Vary = %q, want it to mention Accept-Encoding", got)
}
// The handler's Content-Length describes the uncompressed body; it must be
// gone once the length changes.
if got := rec.Header().Get("Content-Length"); got != "" {
t.Errorf("Content-Length = %q, want empty on a compressed response", got)
}
if got := gunzip(t, rec.Body.Bytes()); !bytes.Equal(got, want) {
t.Errorf("decompressed body does not match original %s (%d vs %d bytes)", name, len(got), len(want))
}
if rec.Body.Len() >= len(want) {
t.Errorf("compressed body %d bytes is not smaller than original %d", rec.Body.Len(), len(want))
}
}
// TestStaticIdentityWithoutAcceptEncoding proves a request with no
// Accept-Encoding is served byte-for-byte, uncompressed.
func TestStaticIdentityWithoutAcceptEncoding(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".js")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty for an identity request", got)
}
if !bytes.Equal(rec.Body.Bytes(), want) {
t.Errorf("identity body does not match original %s", name)
}
}
// TestStaticRangeNotCompressed proves a Range request stays identity: seeking
// depends on the original byte offsets, which gzip would invalidate.
func TestStaticRangeNotCompressed(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".js")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("Range", "bytes=0-99")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty on a Range response", got)
}
if !bytes.Equal(rec.Body.Bytes(), want[:100]) {
t.Errorf("range body does not match the first 100 bytes of %s", name)
}
}
// TestStaticPNGNotCompressed proves binary assets are passed through even when
// the client accepts gzip; re-compressing them wastes CPU.
func TestStaticPNGNotCompressed(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".png")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
req.Header.Set("Accept-Encoding", "gzip")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "image/png") {
t.Fatalf("Content-Type = %q, want image/png", got)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty for a png", got)
}
if !bytes.Equal(rec.Body.Bytes(), want) {
t.Errorf("png body does not match original %s", name)
}
}
// TestStaticIndexCompressedAndNoCache proves the SPA shell still says no-cache
// and is now compressed for gzip clients.
func TestStaticIndexCompressedAndNoCache(t *testing.T) {
srv := newTestServer(t)
want := readAsset(t, "index.html")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Encoding", "gzip")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
t.Errorf("Cache-Control = %q, want no-cache", got)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := gunzip(t, rec.Body.Bytes()); !bytes.Equal(got, want) {
t.Errorf("decompressed index does not match original")
}
}
// ── Wrapper interface forwarding ───────────────────────────────────────
//
// The wrapper sits between the static handler and statusRecorder, so it must
// keep the same interfaces statusRecorder forwards.
func TestGzipResponseWriterForwardsFlush(t *testing.T) {
rec := &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Encoding", "gzip")
gz := newGzipResponseWriter(rec, req)
gz.Header().Set("Content-Type", "text/html; charset=utf-8")
gz.WriteHeader(http.StatusOK)
_, _ = gz.Write([]byte("<html>"))
gz.Flush()
_ = gz.Close()
if !rec.flushed {
t.Error("Flush was not forwarded to the underlying writer")
}
if got := gunzip(t, rec.Body.Bytes()); string(got) != "<html>" {
t.Errorf("flushed body = %q, want <html>", got)
}
}
type hijackRecorder struct {
*httptest.ResponseRecorder
hijacked bool
}
func (h *hijackRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h.hijacked = true
return nil, nil, nil
}
func TestGzipResponseWriterForwardsHijack(t *testing.T) {
rec := &hijackRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
gz := newGzipResponseWriter(rec, req)
if _, _, err := gz.Hijack(); err != nil {
t.Fatalf("Hijack: %v", err)
}
if !rec.hijacked {
t.Error("Hijack was not forwarded to the underlying writer")
}
}
func TestGzipResponseWriterForwardsReadFrom(t *testing.T) {
rec := &readFromRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest(http.MethodGet, "/x", nil) // no Accept-Encoding
gz := newGzipResponseWriter(rec, req)
n, err := gz.ReadFrom(strings.NewReader("payload"))
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if !rec.readFrom {
t.Error("ReadFrom was not forwarded to the underlying writer")
}
if n != int64(len("payload")) || rec.Body.String() != "payload" {
t.Errorf("ReadFrom wrote %d bytes %q, want payload", n, rec.Body.String())
}
}
func TestGzipResponseWriterReadFromCompresses(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Encoding", "gzip")
gz := newGzipResponseWriter(rec, req)
gz.Header().Set("Content-Type", "application/javascript")
n, err := gz.ReadFrom(strings.NewReader("payload"))
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if n != int64(len("payload")) {
t.Errorf("ReadFrom returned %d, want %d", n, len("payload"))
}
_ = gz.Close()
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := gunzip(t, rec.Body.Bytes()); string(got) != "payload" {
t.Errorf("decompressed body = %q, want payload", got)
}
}
// TestCompressibleType and TestAcceptsGzip pin the content-type allow list and
// the Accept-Encoding parsing, including the q=0 refusal.
func TestCompressibleType(t *testing.T) {
compressible := []string{
"text/html", "text/html; charset=utf-8",
"text/css", "application/javascript",
"text/javascript; charset=utf-8",
"application/json", "image/svg+xml",
"application/manifest+json",
}
for _, ct := range compressible {
if !compressibleType(ct) {
t.Errorf("compressibleType(%q) = false, want true", ct)
}
}
skip := []string{
"image/png", "image/jpeg", "font/woff2",
"application/wasm", "application/octet-stream", "",
}
for _, ct := range skip {
if compressibleType(ct) {
t.Errorf("compressibleType(%q) = true, want false", ct)
}
}
}
func TestAcceptsGzip(t *testing.T) {
yes := []string{"gzip", "gzip, deflate", "br, gzip;q=0.5", "GZIP"}
for _, h := range yes {
if !acceptsGzip(h) {
t.Errorf("acceptsGzip(%q) = false, want true", h)
}
}
no := []string{"", "deflate", "br", "gzip;q=0"}
for _, h := range no {
if acceptsGzip(h) {
t.Errorf("acceptsGzip(%q) = true, want false", h)
}
}
}

View file

@ -85,8 +85,14 @@ type engagement struct {
// topEngagement finds the kind and author the user most recently engaged with, // topEngagement finds the kind and author the user most recently engaged with,
// so suggestions can be explained in plain language. // so suggestions can be explained in plain language.
func topEngagement(ctx context.Context, db *sql.DB, userID int64) (engagement, bool, error) { func topEngagement(ctx context.Context, db *sql.DB, userID int64, libraryIDs []int64) (engagement, bool, error) {
var e engagement var e engagement
// Scoped to libraries the user can still read. Without this, progress from a
// library whose grant was revoked would keep steering the home screen's
// suggestions — confusing on its own, and an information-flow leak about
// content the user no longer has access to.
in, inArgs := inClause(libraryIDs)
args := append([]any{userID}, inArgs...)
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
SELECT i.kind, i.title, SELECT i.kind, i.title,
COALESCE((SELECT p.name FROM item_people ip COALESCE((SELECT p.name FROM item_people ip
@ -96,8 +102,9 @@ func topEngagement(ctx context.Context, db *sql.DB, userID int64) (engagement, b
FROM progress p FROM progress p
JOIN media_items i ON i.id = p.item_id JOIN media_items i ON i.id = p.item_id
WHERE p.user_id = ? AND i.missing = 0 WHERE p.user_id = ? AND i.missing = 0
AND i.library_id IN (`+in+`)
ORDER BY p.updated_at DESC ORDER BY p.updated_at DESC
LIMIT 1`, userID).Scan(&e.Kind, &e.Title, &e.Author) LIMIT 1`, args...).Scan(&e.Kind, &e.Title, &e.Author)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return engagement{}, false, nil return engagement{}, false, nil
} }
@ -120,7 +127,7 @@ func Suggestions(ctx context.Context, db *sql.DB, userID int64, libraryIDs []int
limit = 20 limit = 20
} }
e, hasEngagement, err := topEngagement(ctx, db, userID) e, hasEngagement, err := topEngagement(ctx, db, userID, libraryIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -0,0 +1,70 @@
package media
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/mithral/vellum/internal/auth"
)
// getItem drives the per-item detail handler directly. PathValue is normally
// filled in by the ServeMux; the test sets it explicitly.
func getItem(t *testing.T, h *Handlers, u auth.User, id string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/"+id, nil)
req.SetPathValue("id", id)
rec := httptest.NewRecorder()
h.handleItem(rec, req, u)
return rec
}
// TestHandleItemScoping pins the per-item access chokepoint: an item in a
// library the caller cannot read must be indistinguishable from a missing one.
func TestHandleItemScoping(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
// userA may read libA, so a1 resolves.
rec := getItem(t, h, auth.User{ID: f.userA}, itoa(f.a1))
if rec.Code != http.StatusOK {
t.Fatalf("accessible item status = %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Item struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
} `json:"item"`
Authors []string `json:"authors"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode item: %v", err)
}
if body.Item.ID != f.a1 || body.Item.LibraryID != f.libA || body.Item.Kind != "ebooks" {
t.Errorf("item = %+v, want id %d in library %d", body.Item, f.a1, f.libA)
}
if len(body.Authors) != 1 || body.Authors[0] != "Ada Author" {
t.Errorf("authors = %v, want [Ada Author]", body.Authors)
}
// b1 is in libB, which userA cannot read: 404, not 403, so existence is
// not disclosed.
rec = getItem(t, h, auth.User{ID: f.userA}, itoa(f.b1))
if rec.Code != http.StatusNotFound {
t.Errorf("inaccessible item status = %d, want 404", rec.Code)
}
// A user with no grants gets the same 404.
rec = getItem(t, h, auth.User{ID: f.userNone}, itoa(f.a1))
if rec.Code != http.StatusNotFound {
t.Errorf("no-grant item status = %d, want 404", rec.Code)
}
// A non-numeric id is a 400, not a panic.
rec = getItem(t, h, auth.User{ID: f.userA}, "abc")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid id status = %d, want 400", rec.Code)
}
}

728
internal/media/home_test.go Normal file
View file

@ -0,0 +1,728 @@
package media
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
)
// ── shared helpers ─────────────────────────────────────────────────────
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewHandlers(db, log, nil, t.TempDir())
}
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
t.Fatalf("query %q: %v", q, err)
}
return id
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
}
func newUser(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
}
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
t.Helper()
mustExec(t, db,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
userID, libID)
}
// newItem inserts a media item plus a backing file, because progress rows carry
// a NOT NULL file_id foreign key.
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
t.Helper()
m := 0
if missing {
m = 1
}
id := mustQueryID(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
libID, kind, path, title, sortTitle, m, addedAt)
mustExec(t, db,
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
id, "file-"+strconv.FormatInt(id, 10))
return id
}
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(),
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
t.Fatalf("file for item %d: %v", itemID, err)
}
return id
}
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
t.Helper()
f := 0
if finished {
f = 1
}
mustExec(t, db, `
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
}
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
t.Helper()
mustExec(t, db,
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
userID, itemID, views, lastViewedAt)
}
func idSet(items []shelfItem) map[int64]bool {
out := make(map[int64]bool, len(items))
for _, it := range items {
out[it.ID] = true
}
return out
}
func hasDuplicates(items []shelfItem) bool {
seen := map[int64]bool{}
for _, it := range items {
if seen[it.ID] {
return true
}
seen[it.ID] = true
}
return false
}
// ── handleHome ─────────────────────────────────────────────────────────
type homeFixture struct {
db *sql.DB
libA int64 // ebooks/comics, visible to userA
libB int64 // audiobooks, visible to userB
userA int64
userB int64
userNone int64
a1 int64 // in progress (unfinished, most recent)
a2 int64 // finished
a3 int64 // unstarted, viewed, same author as a1
a4 int64 // missing, in progress
a5 int64 // unstarted
b1 int64 // userB's only item
}
func setupHome(t *testing.T) homeFixture {
t.Helper()
db := testDB(t)
f := homeFixture{db: db}
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
f.userA = newUser(t, db, "alice")
f.userB = newUser(t, db, "bob")
f.userNone = newUser(t, db, "carol")
grantRead(t, db, f.userA, f.libA)
grantRead(t, db, f.userB, f.libB)
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
// userA's progress. a1 is the most recent non-missing row, so it drives
// "top engagement". a2 is finished (must leave Continue). a4 is missing
// (must leave Continue even though unfinished).
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
// userB only ever touches their own library.
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
// Ada Author wrote a1 (in progress) and a3 (unstarted) so Suggestions has
// a same-author candidate.
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
return f
}
func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/home", nil)
rec := httptest.NewRecorder()
h.handleHome(rec, req, u)
if rec.Code != http.StatusOK {
t.Fatalf("home status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp homeResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode home: %v (body %s)", err, rec.Body.String())
}
return resp, rec.Body.Bytes()
}
func TestHandleHomeShelvesAndCounts(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
resp, body := getHome(t, h, auth.User{ID: f.userA})
// The landing screen contract: all five shelves plus the sidebar counts.
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatalf("decode raw home: %v", err)
}
for _, key := range []string{"continue", "most_viewed", "suggestions", "recent", "explore", "counts_by_kind", "library_count", "total_items"} {
if _, ok := raw[key]; !ok {
t.Errorf("home response is missing %q", key)
}
}
if resp.Continue == nil || resp.MostViewed == nil || resp.Suggestions == nil || resp.Recent == nil || resp.Explore == nil {
t.Fatalf("a shelf is nil: continue=%v most=%v sugg=%v recent=%v explore=%v",
resp.Continue == nil, resp.MostViewed == nil, resp.Suggestions == nil, resp.Recent == nil, resp.Explore == nil)
}
// Continue: only a1. a2 finished, a4 missing, b1 belongs to another user.
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.a1 {
t.Errorf("continue = %v, want [%d]", got, f.a1)
}
for _, s := range resp.Continue {
if s.Percent <= 0 {
t.Errorf("continue item %d has percent %v", s.ID, s.Percent)
}
}
// Most viewed: a1 (3 views) then a3 (1). b1 is in another library.
if got := ids(resp.MostViewed); len(got) != 2 || got[0] != f.a1 || got[1] != f.a3 {
t.Errorf("most_viewed = %v, want [%d %d]", got, f.a1, f.a3)
}
// Counts exclude the missing item and the invisible library.
if resp.CountsByKind["ebooks"] != 3 || resp.CountsByKind["comics"] != 1 {
t.Errorf("counts_by_kind = %v, want ebooks:3 comics:1", resp.CountsByKind)
}
if _, ok := resp.CountsByKind["audiobooks"]; ok {
t.Errorf("counts_by_kind leaked another library: %v", resp.CountsByKind)
}
if resp.TotalItems != 4 {
t.Errorf("total_items = %d, want 4", resp.TotalItems)
}
if resp.LibraryCount != 1 {
t.Errorf("library_count = %d, want 1", resp.LibraryCount)
}
// Recent excludes the missing item and the other library, newest first.
recent := ids(resp.Recent)
if len(recent) != 4 {
t.Errorf("recent = %v, want 4 libA items", recent)
}
for _, id := range recent {
if id == f.a4 || id == f.b1 {
t.Errorf("recent included excluded item %d", id)
}
}
}
func TestHandleHomeGrantScoping(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
resp, _ := getHome(t, h, auth.User{ID: f.userA})
for name, shelf := range map[string][]shelfItem{
"continue": resp.Continue, "most_viewed": resp.MostViewed,
"recent": resp.Recent, "explore": resp.Explore,
} {
for _, s := range shelf {
if s.LibraryID != f.libA {
t.Errorf("%s item %d leaked from library %d, want only %d", name, s.ID, s.LibraryID, f.libA)
}
}
}
for _, s := range resp.Suggestions {
if s.LibraryID != f.libA {
t.Errorf("suggestion %d leaked from library %d", s.ID, s.LibraryID)
}
}
// A user with no grants gets empty shelves, not an error and not content.
none, _ := getHome(t, h, auth.User{ID: f.userNone})
if none.LibraryCount != 0 || none.TotalItems != 0 || len(none.CountsByKind) != 0 {
t.Errorf("no-grant user got library_count=%d total=%d counts=%v",
none.LibraryCount, none.TotalItems, none.CountsByKind)
}
for name, n := range map[string]int{
"continue": len(none.Continue), "most_viewed": len(none.MostViewed),
"suggestions": len(none.Suggestions), "recent": len(none.Recent), "explore": len(none.Explore),
} {
if n != 0 {
t.Errorf("no-grant user's %s shelf has %d items", name, n)
}
}
}
func TestHandleHomeIsolatesUsers(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
resp, _ := getHome(t, h, auth.User{ID: f.userB})
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 {
t.Errorf("user B continue = %v, want [%d]", got, f.b1)
}
if got := ids(resp.MostViewed); len(got) != 1 || got[0] != f.b1 {
t.Errorf("user B most_viewed = %v, want [%d]", got, f.b1)
}
if resp.CountsByKind["audiobooks"] != 1 || resp.TotalItems != 1 || resp.LibraryCount != 1 {
t.Errorf("user B counts = %v total=%d library_count=%d, want audiobooks:1 total:1 libs:1",
resp.CountsByKind, resp.TotalItems, resp.LibraryCount)
}
for name, shelf := range map[string][]shelfItem{
"continue": resp.Continue, "most_viewed": resp.MostViewed,
"recent": resp.Recent, "explore": resp.Explore,
} {
for _, s := range shelf {
if s.LibraryID != f.libB {
t.Errorf("user B's %s saw library %d", name, s.LibraryID)
}
}
}
}
// ── Explore ────────────────────────────────────────────────────────────
func TestExploreOnlyVisibleAndPresent(t *testing.T) {
db, libA, libB, userA, userB := setupExplore(t)
ctx := context.Background()
var libAIDs []int64
for i := 0; i < 6; i++ {
libAIDs = append(libAIDs, newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%02d", i),
false, "2026-01-01T00:00:00.000Z"))
}
missing := map[int64]bool{}
for i := 0; i < 3; i++ {
missing[newItem(t, db, libA, "ebooks",
fmt.Sprintf("m%d.epub", i), fmt.Sprintf("M%d", i), fmt.Sprintf("m%02d", i),
true, "2026-01-01T00:00:00.000Z")] = true
}
for i := 0; i < 6; i++ {
newItem(t, db, libB, "audiobooks",
fmt.Sprintf("b%d.mp3", i), fmt.Sprintf("B%d", i), fmt.Sprintf("b%02d", i),
false, "2026-01-01T00:00:00.000Z")
}
got, err := Explore(ctx, db, userA, []int64{libA}, 30)
if err != nil {
t.Fatal(err)
}
if len(got) != len(libAIDs) {
t.Fatalf("explore returned %d, want all %d present libA items", len(got), len(libAIDs))
}
seen := idSet(got)
for _, id := range libAIDs {
if !seen[id] {
t.Errorf("explore omitted present item %d", id)
}
}
for _, s := range got {
if s.LibraryID != libA {
t.Errorf("explore item %d came from library %d, want %d", s.ID, s.LibraryID, libA)
}
if missing[s.ID] {
t.Errorf("explore returned missing item %d", s.ID)
}
}
// userB must never see libA content.
gotB, err := Explore(ctx, db, userB, []int64{libB}, 30)
if err != nil {
t.Fatal(err)
}
for _, s := range gotB {
if s.LibraryID != libB {
t.Errorf("user B explore item %d came from library %d", s.ID, s.LibraryID)
}
}
}
// TestExploreExcludesContinueShelf guards the whole point of the shelf: it is
// discovery, so anything the user already has in progress must not reappear.
func TestExploreExcludesContinueShelf(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
var all []int64
for i := 0; i < 20; i++ {
all = append(all, newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%02d", i),
false, "2026-01-01T00:00:00.000Z"))
}
// Three of them are on the Continue shelf.
inProgress := map[int64]bool{all[0]: true, all[7]: true, all[19]: true}
for id := range inProgress {
newProgress(t, db, userA, id, 0.4, false, "2026-02-01T00:00:00.000Z")
}
// Repeated calls, because Explore samples a random window per kind.
for i := 0; i < 50; i++ {
got, err := Explore(ctx, db, userA, []int64{libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("explore returned nothing")
}
for _, s := range got {
if inProgress[s.ID] {
t.Fatalf("call %d returned in-progress item %d", i, s.ID)
}
}
}
}
// TestExploreRepresentsEveryKind checks the fair-share split: when the limit is
// large enough, every kind present in the visible libraries must appear.
func TestExploreRepresentsEveryKind(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
kinds := []string{"ebooks", "comics", "audiobooks"}
for _, kind := range kinds {
for i := 0; i < 12; i++ {
newItem(t, db, libA, kind,
fmt.Sprintf("%s-%d.dat", kind, i), fmt.Sprintf("%s %d", kind, i),
fmt.Sprintf("%s-%02d", kind, i), false, "2026-01-01T00:00:00.000Z")
}
}
got, err := Explore(ctx, db, userA, []int64{libA}, 30)
if err != nil {
t.Fatal(err)
}
if len(got) != 30 {
t.Fatalf("explore returned %d items, want the full limit 30", len(got))
}
if hasDuplicates(got) {
t.Errorf("explore returned duplicate items: %v", ids(got))
}
present := map[string]bool{}
for _, s := range got {
present[s.Kind] = true
}
for _, kind := range kinds {
if !present[kind] {
t.Errorf("kind %q missing from explore shelf %v", kind, ids(got))
}
}
}
// TestExploreNoDuplicates exercises the wrap-around path: the result set must
// never repeat an item even though a kind may be filled from both halves of the
// id range.
func TestExploreNoDuplicates(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
for i := 0; i < 40; i++ {
newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%02d", i),
false, "2026-01-01T00:00:00.000Z")
}
for i := 0; i < 100; i++ {
got, err := Explore(ctx, db, userA, []int64{libA}, 15)
if err != nil {
t.Fatal(err)
}
if hasDuplicates(got) {
t.Fatalf("call %d returned duplicates: %v", i, ids(got))
}
}
}
// TestExploreVariesAcrossCalls pins the fix for the old deterministic ordering:
// with far more items than the limit, repeated visits must not return the same
// slice every time. It asserts variety (an invariant of the sampler) rather
// than a particular order, so it is not sensitive to the random seed.
func TestExploreVariesAcrossCalls(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
for i := 0; i < 120; i++ {
newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%03d", i),
false, "2026-01-01T00:00:00.000Z")
}
variants := map[string]bool{}
for i := 0; i < 30; i++ {
got, err := Explore(ctx, db, userA, []int64{libA}, 30)
if err != nil {
t.Fatal(err)
}
variants[signature(got)] = true
}
if len(variants) < 2 {
t.Fatalf("explore returned an identical shelf on all 30 calls (%d items each); the sampler is deterministic", len(variants))
}
}
// TestExploreCompleteWhenLimitCoversEverything checks the short-result /
// wrap-around path deterministically: regardless of where the random start
// lands, a limit larger than the library must yield the whole eligible set.
func TestExploreCompleteWhenLimitCoversEverything(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
var want []int64
for _, kind := range []string{"ebooks", "comics"} {
for i := 0; i < 4; i++ {
want = append(want, newItem(t, db, libA, kind,
fmt.Sprintf("%s-%d.dat", kind, i), fmt.Sprintf("%s %d", kind, i),
fmt.Sprintf("%s-%02d", kind, i), false, "2026-01-01T00:00:00.000Z"))
}
}
got, err := Explore(ctx, db, userA, []int64{libA}, 100)
if err != nil {
t.Fatal(err)
}
seen := idSet(got)
for _, id := range want {
if !seen[id] {
t.Errorf("limit above library size omitted item %d", id)
}
}
if len(got) != len(want) {
t.Errorf("explore returned %d items, want exactly %d", len(got), len(want))
}
}
// signature renders an item slice as a stable string for set comparison.
func signature(items []shelfItem) string {
ids := ids(items)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatInt(id, 10)
}
return strings.Join(parts, ",")
}
// ── RecordView / MostViewed ────────────────────────────────────────────
func TestMostViewedOrdersByViewsThenRecency(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
older := newItem(t, db, libA, "ebooks", "older.epub", "Older", "older", false, "2026-01-01T00:00:00.000Z")
newer := newItem(t, db, libA, "ebooks", "newer.epub", "Newer", "newer", false, "2026-01-01T00:00:00.000Z")
most := newItem(t, db, libA, "ebooks", "most.epub", "Most", "most", false, "2026-01-01T00:00:00.000Z")
// Equal view counts, different recency: the newer one must win.
newView(t, db, userA, older, 5, "2026-03-01T00:00:00.000Z")
newView(t, db, userA, newer, 5, "2026-03-02T00:00:00.000Z")
// More views always outranks recency.
newView(t, db, userA, most, 9, "2026-01-01T00:00:00.000Z")
got, err := MostViewed(ctx, db, userA, []int64{libA}, 10)
if err != nil {
t.Fatal(err)
}
want := []int64{most, newer, older}
if len(got) != len(want) {
t.Fatalf("most viewed = %v, want %v", ids(got), want)
}
for i := range want {
if got[i].ID != want[i] {
t.Errorf("most viewed[%d] = %d, want %d (full order %v)", i, got[i].ID, want[i], ids(got))
}
}
}
func TestMostViewedExcludesMissing(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
present := newItem(t, db, libA, "ebooks", "p.epub", "P", "p", false, "2026-01-01T00:00:00.000Z")
gone := newItem(t, db, libA, "ebooks", "g.epub", "G", "g", true, "2026-01-01T00:00:00.000Z")
newView(t, db, userA, present, 1, "2026-03-01T00:00:00.000Z")
newView(t, db, userA, gone, 50, "2026-03-02T00:00:00.000Z")
got, err := MostViewed(ctx, db, userA, []int64{libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != present {
t.Errorf("most viewed = %v, want only the present item %d", ids(got), present)
}
}
// TestRecordViewIsPerUser guards the (user_id, item_id) key: one user's habit
// must not move another user's count.
func TestRecordViewIsPerUser(t *testing.T) {
db, libA, _, userA, userB := setupExplore(t)
ctx := context.Background()
item := newItem(t, db, libA, "ebooks", "x.epub", "X", "x", false, "2026-01-01T00:00:00.000Z")
// userB is not granted libA, but RecordView itself is keyed by user id.
grantRead(t, db, userB, libA)
for i := 1; i <= 3; i++ {
n, err := RecordView(ctx, db, userA, item)
if err != nil {
t.Fatal(err)
}
if n != i {
t.Errorf("user A view %d returned %d", i, n)
}
}
n, err := RecordView(ctx, db, userB, item)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("user B's first view returned %d, want 1 (counts are per user)", n)
}
}
// ── Suggestions ────────────────────────────────────────────────────────
func TestSuggestionsReasonsAndUniqueness(t *testing.T) {
f := setupHome(t)
ctx := context.Background()
sugg, err := Suggestions(ctx, f.db, f.userA, []int64{f.libA}, 20)
if err != nil {
t.Fatal(err)
}
if len(sugg) == 0 {
t.Fatal("no suggestions")
}
seen := map[int64]bool{}
for _, s := range sugg {
if s.Reason == "" {
t.Errorf("suggestion %d has an empty reason", s.ID)
}
if seen[s.ID] {
t.Errorf("suggestion %d returned twice", s.ID)
}
seen[s.ID] = true
if s.LibraryID != f.libA {
t.Errorf("suggestion %d leaked from library %d", s.ID, s.LibraryID)
}
}
// a1 is in progress, so it must not be suggested; a3 (same author) and a5
// (same kind) are the unstarted candidates.
if seen[f.a1] {
t.Error("an in-progress item was suggested")
}
if !seen[f.a3] || !seen[f.a5] {
t.Errorf("expected same-author a3 and same-kind a5, got %v", idsOfSuggestions(sugg))
}
}
func idsOfSuggestions(s []suggestion) []int64 {
out := make([]int64, len(s))
for i := range s {
out[i] = s[i].ID
}
return out
}
// TestSuggestionsSeedIsNotScopedToVisibleLibraries documents a hardening gap,
// NOT desired behaviour.
//
// topEngagement() picks the user's most recent progress row across every
// library, including ones the user can no longer read (e.g. a revoked grant).
// The items Suggestions returns are still correctly scoped to the visible
// libraries, so nothing hidden is ever listed — but the inaccessible row's
// kind/author still shapes the shelf. Here a hidden library's ebooks progress
// makes the visible ebooks item come back as "Recently added" rather than being
// mislabelled "More books" from a library the user cannot read.
//
// The seed (topEngagement) is now scoped to visible libraries. Before that, a
// revoked library's most recent progress row still steered the home screen:
// the item below was labelled "More books" — a suggestion reason derived from
// content the user no longer had access to.
func TestSuggestionsSeedIsScopedToVisibleLibraries(t *testing.T) {
db := testDB(t)
ctx := context.Background()
visible := newLibrary(t, db, "Seed Visible", "ebooks", "/seed/v")
hidden := newLibrary(t, db, "Seed Hidden", "ebooks", "/seed/h")
u := newUser(t, db, "seed-user")
grantRead(t, db, u, visible)
newItem(t, db, visible, "ebooks", "v1.epub", "V1", "v1", false, "2026-01-01T00:00:00.000Z")
hid := newItem(t, db, hidden, "ebooks", "h1.epub", "H1", "h1", false, "2026-01-01T00:00:00.000Z")
newProgress(t, db, u, hid, 0.5, false, "2026-02-01T00:00:00.000Z")
sugg, err := Suggestions(ctx, db, u, []int64{visible}, 20)
if err != nil {
t.Fatal(err)
}
if len(sugg) != 1 {
t.Fatalf("suggestions = %d, want 1", len(sugg))
}
if sugg[0].LibraryID != visible {
t.Errorf("suggestion leaked library %d", sugg[0].LibraryID)
}
if sugg[0].Reason != "Recently added" {
t.Errorf("reason = %q; want \"Recently added\" — a revoked library's progress must not label the suggestion", sugg[0].Reason)
}
}
// setupExplore builds two libraries with one user each and returns them.
func setupExplore(t *testing.T) (db *sql.DB, libA, libB, userA, userB int64) {
t.Helper()
db = testDB(t)
libA = newLibrary(t, db, "Explore A", "ebooks", "/explore/a")
libB = newLibrary(t, db, "Explore B", "audiobooks", "/explore/b")
userA = newUser(t, db, "explore-a")
userB = newUser(t, db, "explore-b")
grantRead(t, db, userA, libA)
grantRead(t, db, userB, libB)
return db, libA, libB, userA, userB
}

View file

@ -0,0 +1,250 @@
package media
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/mithral/vellum/internal/auth"
)
// listResponse mirrors the JSON contract of handleListItems.
type listResponse struct {
Items []itemRow `json:"items"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func listItems(t *testing.T, h *Handlers, u auth.User, query string) (int, listResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items"+query, nil)
rec := httptest.NewRecorder()
h.handleListItems(rec, req, u)
var resp listResponse
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode list: %v (body %s)", err, rec.Body.String())
}
}
return rec.Code, resp
}
type listFixture struct {
db *sql.DB
libA int64
libB int64
userA int64
userNone int64
e1 int64 // libA ebooks, sort 01
e2 int64 // libA ebooks, sort 02
e3 int64 // libA ebooks, sort 03
c1 int64 // libA comics, sort 04
m1 int64 // libA ebooks, missing
b1 int64 // libB audiobooks
}
func setupList(t *testing.T) listFixture {
t.Helper()
db := testDB(t)
f := listFixture{db: db}
f.libA = newLibrary(t, db, "List A", "ebooks", "/list/a")
f.libB = newLibrary(t, db, "List B", "audiobooks", "/list/b")
f.userA = newUser(t, db, "list-a")
f.userNone = newUser(t, db, "list-none")
grantRead(t, db, f.userA, f.libA)
const at = "2026-01-01T00:00:00.000Z"
f.e1 = newItem(t, db, f.libA, "ebooks", "e1.epub", "Echo", "01 echo", false, at)
f.e2 = newItem(t, db, f.libA, "ebooks", "e2.epub", "Delta", "02 delta", false, at)
f.e3 = newItem(t, db, f.libA, "ebooks", "e3.epub", "Charlie", "03 charlie", false, at)
f.c1 = newItem(t, db, f.libA, "comics", "c1.cbz", "Bravo", "04 bravo", false, at)
f.m1 = newItem(t, db, f.libA, "ebooks", "m1.epub", "Missing", "05 missing", true, at)
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "Alpha", "06 alpha", false, at)
return f
}
// TestListItemsTotalIsNotThePage pins the recently added offset/limit/total
// contract: total counts every matching row, independent of the page window.
func TestListItemsTotalIsNotThePage(t *testing.T) {
f := setupList(t)
h := mediaHandlers(t, f.db)
code, resp := listItems(t, h, auth.User{ID: f.userA}, "?limit=2")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if resp.Total != 4 {
t.Errorf("total = %d, want 4 (all matching present rows, not the page)", resp.Total)
}
if len(resp.Items) != 2 {
t.Errorf("page has %d items, want 2", len(resp.Items))
}
if resp.Limit != 2 || resp.Offset != 0 {
t.Errorf("limit/offset = %d/%d, want 2/0", resp.Limit, resp.Offset)
}
for _, it := range resp.Items {
if it.ID == f.m1 {
t.Error("a missing item was listed")
}
}
}
func TestListItemsPaging(t *testing.T) {
f := setupList(t)
h := mediaHandlers(t, f.db)
u := auth.User{ID: f.userA}
_, p1 := listItems(t, h, u, "?limit=2&offset=0")
_, p2 := listItems(t, h, u, "?limit=2&offset=2")
if len(p1.Items) != 2 || len(p2.Items) != 2 {
t.Fatalf("page sizes = %d/%d, want 2/2", len(p1.Items), len(p2.Items))
}
if p1.Offset != 0 || p2.Offset != 2 {
t.Errorf("offsets = %d/%d, want 0/2", p1.Offset, p2.Offset)
}
// Pages must not overlap, and must order by sort_title.
if p1.Items[0].ID != f.e1 || p1.Items[1].ID != f.e2 {
t.Errorf("page 1 = %v, want [%d %d]", idsFromRows(p1.Items), f.e1, f.e2)
}
if p2.Items[0].ID != f.e3 || p2.Items[1].ID != f.c1 {
t.Errorf("page 2 = %v, want [%d %d]", idsFromRows(p2.Items), f.e3, f.c1)
}
seen := map[int64]bool{}
for _, it := range append(append([]itemRow{}, p1.Items...), p2.Items...) {
if seen[it.ID] {
t.Errorf("item %d appears on both pages", it.ID)
}
seen[it.ID] = true
}
// A page past the end is empty but still reports the true total.
_, beyond := listItems(t, h, u, "?limit=2&offset=99")
if len(beyond.Items) != 0 || beyond.Total != 4 {
t.Errorf("beyond page: items=%d total=%d, want 0/4", len(beyond.Items), beyond.Total)
}
}
func TestListItemsFiltersCompose(t *testing.T) {
f := setupList(t)
h := mediaHandlers(t, f.db)
u := auth.User{ID: f.userA}
_, ebooks := listItems(t, h, u, "?kind=ebooks")
if ebooks.Total != 3 {
t.Errorf("kind=ebooks total = %d, want 3", ebooks.Total)
}
for _, it := range ebooks.Items {
if it.Kind != "ebooks" {
t.Errorf("kind filter returned a %q item", it.Kind)
}
}
_, libA := listItems(t, h, u, "?library_id="+itoa(f.libA))
if libA.Total != 4 {
t.Errorf("library_id=%d total = %d, want 4", f.libA, libA.Total)
}
// kind and library_id compose (both must hold).
_, both := listItems(t, h, u, "?kind=ebooks&library_id="+itoa(f.libA))
if both.Total != 3 {
t.Errorf("kind+library total = %d, want 3", both.Total)
}
for _, it := range both.Items {
if it.Kind != "ebooks" || it.LibraryID != f.libA {
t.Errorf("composed filter returned %+v", it)
}
}
// A kind with no items is an empty page, not an error.
code, none := listItems(t, h, u, "?kind=movies")
if code != http.StatusOK || none.Total != 0 || len(none.Items) != 0 {
t.Errorf("unknown kind: status=%d total=%d items=%d", code, none.Total, len(none.Items))
}
}
func TestListItemsScoping(t *testing.T) {
f := setupList(t)
h := mediaHandlers(t, f.db)
// A user with no grants sees nothing.
_, none := listItems(t, h, auth.User{ID: f.userNone}, "?limit=100")
if none.Total != 0 || len(none.Items) != 0 {
t.Errorf("no-grant user saw total=%d items=%d", none.Total, len(none.Items))
}
// Asking explicitly for a library the user cannot read must not leak it.
code, leak := listItems(t, h, auth.User{ID: f.userA}, "?library_id="+itoa(f.libB)+"&limit=100")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if leak.Total != 0 || len(leak.Items) != 0 {
t.Errorf("user A read library %d: total=%d items=%d", f.libB, leak.Total, len(leak.Items))
}
// The unfiltered list contains only the readable library.
_, all := listItems(t, h, auth.User{ID: f.userA}, "?limit=100")
for _, it := range all.Items {
if it.LibraryID != f.libA {
t.Errorf("item %d leaked from library %d", it.ID, it.LibraryID)
}
}
if containsRowID(all.Items, f.b1) {
t.Error("user A's list contained another user's item")
}
}
func TestListItemsInvalidLibraryID(t *testing.T) {
f := setupList(t)
h := mediaHandlers(t, f.db)
for _, q := range []string{"?library_id=abc", "?library_id=-1", "?library_id=0"} {
code, _ := listItems(t, h, auth.User{ID: f.userA}, q)
if code != http.StatusBadRequest {
t.Errorf("%s status = %d, want 400", q, code)
}
}
}
func TestListItemsDefaultsAndCaps(t *testing.T) {
f := setupList(t)
h := mediaHandlers(t, f.db)
u := auth.User{ID: f.userA}
_, def := listItems(t, h, u, "")
if def.Limit != 300 || def.Offset != 0 {
t.Errorf("defaults = limit %d offset %d, want 300/0", def.Limit, def.Offset)
}
// Out-of-range limit and negative offset fall back to the defaults.
for _, q := range []string{"?limit=0", "?limit=5000", "?limit=-1"} {
_, r := listItems(t, h, u, q)
if r.Limit != 300 {
t.Errorf("%s limit = %d, want 300", q, r.Limit)
}
}
_, neg := listItems(t, h, u, "?offset=-5")
if neg.Offset != 0 {
t.Errorf("negative offset = %d, want 0", neg.Offset)
}
}
func idsFromRows(rows []itemRow) []int64 {
out := make([]int64, len(rows))
for i, r := range rows {
out[i] = r.ID
}
return out
}
func containsRowID(rows []itemRow, id int64) bool {
for _, r := range rows {
if r.ID == id {
return true
}
}
return false
}

View file

@ -1,4 +1,5 @@
// Package search queries the FTS5 index over media item titles and paths. // Package search queries the FTS5 index over media item titles, paths, and
// credited people (authors/artists).
package search package search
import ( import (
@ -15,6 +16,9 @@ type Result struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Path string `json:"path"` Path string `json:"path"`
Title string `json:"title"` Title string `json:"title"`
// Authors is the space-joined credit text denormalised onto media_items by
// migration 0009. It is exposed so a caller can show why a result matched.
Authors string `json:"authors"`
} }
// Query searches visible libraries for a free-text string. // Query searches visible libraries for a free-text string.
@ -44,8 +48,11 @@ func Query(ctx context.Context, db *sql.DB, q string, libraryIDs []int64, limit
} }
args = append(args, limit) args = append(args, limit)
// The MATCH predicate is unchanged: media_search now indexes `authors` too
// (migration 0009), so author terms match with no query-side change. The
// column is only selected so callers can display it.
query := ` query := `
SELECT i.id, i.library_id, i.kind, i.path, i.title SELECT i.id, i.library_id, i.kind, i.path, i.title, i.authors
FROM media_search s FROM media_search s
JOIN media_items i ON i.id = s.rowid JOIN media_items i ON i.id = s.rowid
WHERE media_search MATCH ? WHERE media_search MATCH ?
@ -63,7 +70,7 @@ func Query(ctx context.Context, db *sql.DB, q string, libraryIDs []int64, limit
out := []Result{} out := []Result{}
for rows.Next() { for rows.Next() {
var r Result var r Result
if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Path, &r.Title); err != nil { if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Path, &r.Title, &r.Authors); err != nil {
return nil, err return nil, err
} }
out = append(out, r) out = append(out, r)

View file

@ -50,6 +50,26 @@ func addItem(t *testing.T, db *sql.DB, libraryID int64, kind, path, title string
return id return id
} }
func addPerson(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO people (name) VALUES (?) RETURNING id`, name).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func addCredit(t *testing.T, db *sql.DB, itemID, personID int64, role string, ord int) {
t.Helper()
if _, err := db.ExecContext(context.Background(),
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, ?, ?)`,
itemID, personID, role, ord); err != nil {
t.Fatal(err)
}
}
func titles(results []Result) []string { func titles(results []Result) []string {
out := make([]string, len(results)) out := make([]string, len(results))
for i, r := range results { for i, r := range results {
@ -101,6 +121,84 @@ func TestQueryFindsItems(t *testing.T) {
} }
} }
func TestQueryFindsItemsByAuthor(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "notes.epub", "Notes on the Analytical Engine")
ada := addPerson(t, db, "Ada Lovelace")
addCredit(t, db, id, ada, "author", 0)
// The credit is not in the title or path, so this only passes if the FTS
// index covers the denormalised authors column.
res, err := Query(ctx, db, "lovelace", []int64{lib}, 50)
if err != nil {
t.Fatalf("Query: %v", err)
}
if len(res) != 1 || res[0].ID != id {
t.Fatalf("author search results = %v", titles(res))
}
if res[0].Authors != "Ada Lovelace" {
t.Errorf("Authors = %q, want %q", res[0].Authors, "Ada Lovelace")
}
}
// TestAuthorIndexStaysInSync exercises every trigger 0009 installs on the
// credit tables: insert, delete, rename (people UPDATE), and moving a credit
// between items (item_people UPDATE).
func TestAuthorIndexStaysInSync(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "notes.epub", "Notes")
ada := addPerson(t, db, "Ada Lovelace")
addCredit(t, db, id, ada, "author", 0)
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 1 {
t.Fatal("credit insert did not reach the index")
}
// Deleting the credit must remove the author from the index.
if _, err := db.ExecContext(ctx,
`DELETE FROM item_people WHERE item_id = ? AND person_id = ?`, id, ada); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 0 {
t.Error("stale author survived the credit delete")
}
// Re-add, then rename: the old name must stop matching and the new start.
addCredit(t, db, id, ada, "author", 0)
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 1 {
t.Fatal("re-added credit did not reach the index")
}
if _, err := db.ExecContext(ctx,
`UPDATE people SET name = 'Ada King' WHERE id = ?`, ada); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 0 {
t.Error("old name survived the rename")
}
if res, _ := Query(ctx, db, "king", []int64{lib}, 50); len(res) != 1 {
t.Error("new name did not reach the index after the rename")
}
// Moving the credit to another item re-indexes both ends.
other := addItem(t, db, lib, "ebooks", "other.epub", "Other")
if _, err := db.ExecContext(ctx,
`UPDATE item_people SET item_id = ? WHERE item_id = ? AND person_id = ?`,
other, id, ada); err != nil {
t.Fatal(err)
}
res, err := Query(ctx, db, "king", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 1 || res[0].ID != other {
t.Errorf("moved credit: results = %v", titles(res))
}
}
func TestQueryIsScopedToLibraries(t *testing.T) { func TestQueryIsScopedToLibraries(t *testing.T) {
db := testDB(t) db := testDB(t)
ctx := context.Background() ctx := context.Background()

View file

@ -0,0 +1,120 @@
-- 0009_search_authors.sql — make credited people (authors, artists, directors)
-- searchable.
--
-- 0003 indexed only `title` and `path`, so typing an author's name found
-- nothing even though `people` and `item_people` already held the credits.
-- Rather than make the search query join the credit tables — which would mean a
-- second MATCH against a second index, or an unindexed LIKE over a join — the
-- names are denormalised into a real column on `media_items`. The existing
-- external-content FTS5 design then keeps working unchanged in spirit: the
-- index still stores no text of its own, and the media_items triggers that
-- already keep it in sync simply gain the new column.
--
-- `authors` is maintained by triggers on item_people/people, so a credit change
-- re-indexes the item no matter which code path writes it — the metadata
-- enricher today, anything else later.
ALTER TABLE media_items ADD COLUMN authors TEXT NOT NULL DEFAULT '';
-- Drop the old two-column index and its triggers before rebuilding.
-- Triggers first: dropping the virtual table does not drop triggers that
-- reference it, and a write landing between the two drops would fire a trigger
-- against a table that no longer exists.
DROP TRIGGER media_items_ai;
DROP TRIGGER media_items_ad;
DROP TRIGGER media_items_au;
DROP TABLE media_search;
CREATE VIRTUAL TABLE media_search USING fts5(
title,
path,
authors,
content='media_items',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
-- Populate the fresh index from current content before the backfill below.
-- FTS5's external-content 'delete' command assumes the rowid is already in the
-- index; issuing it for an absent rowid raises "database disk image is
-- malformed". The backfill UPDATE fires media_items_au (a delete+insert) for
-- every existing row, so the index must first hold those rows. On a fresh
-- database this indexes nothing and is a no-op.
INSERT INTO media_search(media_search) VALUES ('rebuild');
-- Recreate the media_items triggers verbatim from 0003, with `authors` added,
-- so every direct write to an item keeps the index correct.
CREATE TRIGGER media_items_ai AFTER INSERT ON media_items BEGIN
INSERT INTO media_search(rowid, title, path, authors)
VALUES (new.id, new.title, new.path, new.authors);
END;
CREATE TRIGGER media_items_ad AFTER DELETE ON media_items BEGIN
INSERT INTO media_search(media_search, rowid, title, path, authors)
VALUES ('delete', old.id, old.title, old.path, old.authors);
END;
CREATE TRIGGER media_items_au AFTER UPDATE ON media_items BEGIN
INSERT INTO media_search(media_search, rowid, title, path, authors)
VALUES ('delete', old.id, old.title, old.path, old.authors);
INSERT INTO media_search(rowid, title, path, authors)
VALUES (new.id, new.title, new.path, new.authors);
END;
-- Credit triggers. Each recomputes media_items.authors for the affected item.
-- That UPDATE is itself on media_items, so media_items_au fires and rewrites
-- the FTS row — no FTS-specific code is needed here. (This is a single level of
-- trigger nesting, not recursion; and even with PRAGMA recursive_triggers=1 the
-- chain terminates because the FTS write targets the virtual table, not
-- media_items.)
CREATE TRIGGER item_people_ai AFTER INSERT ON item_people BEGIN
UPDATE media_items SET authors = COALESCE((
SELECT group_concat(p.name, ' ') FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = media_items.id), '')
WHERE id = new.item_id;
END;
CREATE TRIGGER item_people_ad AFTER DELETE ON item_people BEGIN
UPDATE media_items SET authors = COALESCE((
SELECT group_concat(p.name, ' ') FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = media_items.id), '')
WHERE id = old.item_id;
END;
-- An UPDATE can move a credit between items, so both ends are recomputed.
CREATE TRIGGER item_people_au AFTER UPDATE ON item_people BEGIN
UPDATE media_items SET authors = COALESCE((
SELECT group_concat(p.name, ' ') FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = media_items.id), '')
WHERE id = old.item_id;
UPDATE media_items SET authors = COALESCE((
SELECT group_concat(p.name, ' ') FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = media_items.id), '')
WHERE id = new.item_id;
END;
-- A rename must not leave the old spelling in the index: recompute every item
-- credited to this person. people.name is UNIQUE COLLATE NOCASE, so renames are
-- rare, but a stale denormalised column is exactly what a rename would expose.
CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
UPDATE media_items SET authors = COALESCE((
SELECT group_concat(p.name, ' ') FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = media_items.id), '')
WHERE id IN (SELECT item_id FROM item_people WHERE person_id = old.id);
END;
-- Backfill: the triggers above only fire on future writes, so populate every
-- existing row once. The final rebuild re-derives the whole index from the
-- content table, which also normalises any transient FTS state left by the
-- per-row updates above.
UPDATE media_items SET authors = COALESCE((
SELECT group_concat(p.name, ' ') FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = media_items.id), '');
INSERT INTO media_search(media_search) VALUES ('rebuild');

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import ePub from 'epubjs'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type EbookMetadata } from '../api' import { api, ApiError, type Item, type EbookMetadata } from '../api'
import ComicReader from './ComicReader.svelte' import ComicReader from './ComicReader.svelte'
@ -67,6 +66,9 @@
}) })
async function openBook(startIndex: number) { async function openBook(startIndex: number) {
// epub.js is ~200 KB and most people never open an ebook. Load it on demand
// so it lands in its own chunk instead of the main bundle.
const ePub = (await import('epubjs')).default
book = ePub(api.fileUrl(itemId), { openAs: 'epub' }) book = ePub(api.fileUrl(itemId), { openAs: 'epub' })
rendition = book.renderTo(container, { rendition = book.renderTo(container, {
width: '100%', width: '100%',