Files
bookhoard/cmd/server/tests/metadata_write_test.go
T
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00

445 lines
18 KiB
Go

package main
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27):
// POST /api/media-items/:id/write-metadata (admin only) persists the
// item's CURRENT database metadata into the artifact — embedded OPF for
// EPUBs, a Calibre-style sidecar for formats that can't carry metadata
// — then re-extracts. Sidecar naming: metadata.opf when the item is the
// only book in its directory, <basename>.opf otherwise (one book's
// sidecar must never poison dir-mates).
//
// Fixture files live under /app/uploads (the container's library
// folder) and are removed on cleanup; the test library delete
// cascades the items.
func TestMetadataWriteToFile(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true)
uploadsDir := "/app/uploads"
insertItem := func(relPath, title string) string {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, title, relPath,
).Scan(&itemID)
require.NoError(t, err)
return itemID
}
writeEPUB := func(absPath, title string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755))
var buf bytes.Buffer
w := zip.NewWriter(&buf)
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
_, _ = mimetype.Write([]byte("application/epub+zip"))
container, _ := w.Create("META-INF/container.xml")
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
opf, _ := w.Create("OEBPS/content.opf")
_, _ = opf.Write([]byte(fmt.Sprintf(`<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">%s</dc:title><dc:creator>Original Author</dc:creator><dc:identifier id="pub-id">urn:uuid:abcd</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>`, title)))
ch, _ := w.Create("OEBPS/c1.xhtml")
_, _ = ch.Write([]byte("<html><body>x</body></html>"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644))
}
callWrite := func(token, itemID string) (int, map[string]interface{}) {
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var body map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body
}
// Edit the item's metadata the way the editor does (PUT), then ask
// the endpoint to persist it.
editItem := func(itemID string, fields map[string]interface{}) {
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed")
}
t.Run("non-admin is forbidden", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
writeEPUB(abs, "Forbidden Book")
itemID := insertItem(rel, "Forbidden Book")
code, _ := callWrite(setup.RegularToken, itemID)
assert.Equal(t, http.StatusForbidden, code)
})
t.Run("missing file errors without sidecar litter", func(t *testing.T) {
itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book")
code, body := callWrite(setup.Token, itemID)
assert.Equal(t, http.StatusInternalServerError, code)
assert.Contains(t, body["error"], "missing on disk")
})
t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
writeEPUB(abs, "Original EPUB Title")
itemID := insertItem(rel, "Original EPUB Title")
editItem(itemID, map[string]interface{}{
"title": "Embedded New Title",
"authors": []string{"Embedded Author"},
"series": "Embedded Series",
"series_number": 2,
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "embedded-opf", body["metadata_target"])
// The zip on disk now carries the new values — verified by
// PARSING the rewritten OPF (not string matching), with the
// ORIGINAL unique-identifier surviving the splice.
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var opfContent []byte
for _, f := range reader.File {
if f.Name == "OEBPS/content.opf" {
rc, err := f.Open()
require.NoError(t, err)
opfContent, err = io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
}
}
reader.Close()
var opf struct {
Metadata struct {
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Metas []struct {
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
}
require.NoError(t, xml.Unmarshal(opfContent, &opf))
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
require.NotEmpty(t, opf.Metadata.Creators)
var seriesMeta string
for _, m := range opf.Metadata.Metas {
if m.Name == "calibre:series" {
seriesMeta = m.Content
}
}
assert.Equal(t, "Embedded Series", seriesMeta)
assert.Contains(t, string(opfContent), "urn:uuid:abcd", "original identifier preserved for unique-identifier")
// The inline rescan re-extracted from the file: the DB title
// still reads the written value.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Embedded New Title", title)
})
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "sidecar-opf", body["metadata_target"])
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
data, err := os.ReadFile(sidecar)
require.NoError(t, err, "metadata.opf must exist next to a lone book")
// Parse the sidecar like the scanner does: title must be present
// as a dc:title ELEMENT, not just a substring.
var opf struct {
Metadata struct {
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
} `xml:"metadata"`
}
require.NoError(t, xml.Unmarshal(data, &opf))
require.NotEmpty(t, opf.Metadata.Titles)
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
})
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
// PDFs can't carry metadata, so they take the sidecar path —
// and with two books in one directory, the sidecar must be
// per-file or A's write would poison B's next scan.
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
var itemA string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, relA,
).Scan(&itemA)
require.NoError(t, err)
var itemB string
err = setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, relB,
).Scan(&itemB)
require.NoError(t, err)
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
code, body := callWrite(setup.Token, itemA)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "sidecar-opf", body["metadata_target"])
// a.opf carries A's metadata; NO dir-scoped metadata.opf
// may appear (it would poison B's next scan), and B gets no
// sidecar from A's write. The name strips the extension,
// matching the scanner's {basename}.jpg cover convention.
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
require.NoError(t, err, "shared dir must use <basename>.opf")
assert.Contains(t, string(data), "Book A Rewritten")
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
_ = itemB
})
t.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
// Each single-file container gets its own generated fixture
// (ffmpeg at test runtime — no binary media in git), its own
// write, and an INDEPENDENT verification pass: ffprobe the
// rewritten file and assert every mapped key landed. This is the
// "check the file's metadata yourself" contract per format:
//
// album/artist/composer/grouping/date/genre → all four
// subtitle → mp3 + flac only
// (the mp4 muxer
// drops it)
cases := []struct {
ext string
audioCodec string
wantSubtitle bool
}{
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
}
for _, tc := range cases {
t.Run(tc.ext, func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "2", "-c:a", tc.audioCodec,
"-metadata", "title=Generated Fixture",
"-metadata", "album=Generated Fixture",
abs)
if out, err := gen.CombinedOutput(); err != nil {
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
}
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
).Scan(&itemID)
require.NoError(t, err)
// Follow the real editor flow: PUT first (records
// metadata_overrides, so the inline rescan keeps the
// edited values instead of folder-structure derivations).
editItem(itemID, map[string]interface{}{
"title": "Audio New Title",
"subtitle": "Audio Sub",
"authors": []string{"Audio Author"},
"narrators": []string{"Narrator Person"},
"series": "Audio Series",
"series_number": 1.5,
"date_published": "1953-04-16",
"tags": []string{"Science"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
// ---- verify the FILE's metadata independently ----
probe := func() map[string]string {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format_tags", "-of", "json", abs).Output()
require.NoError(t, err)
var parsed struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
return parsed.Format.Tags
}
tags := probe()
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
assert.Equal(t, "Science", tags["genre"], tc.ext)
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
if tc.wantSubtitle {
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
} else {
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
writeMap := body["write"].(map[string]interface{})
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
}
// Still valid, same-length audio after the rewrite.
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
require.NoError(t, err)
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
require.NoError(t, err)
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
// The inline rescan re-read the tags: the DB title stays
// the written one.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Audio New Title", title, tc.ext)
})
}
})
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
// Minimal archive: one "page" file (a text stub — cover
// extraction failing is tolerated by the scanner) and no
// ComicInfo.xml yet.
var buf bytes.Buffer
w := zip.NewWriter(&buf)
page, _ := w.Create("page_0001.jpg")
_, _ = page.Write([]byte("not-a-real-jpeg"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{
"title": "Comic New Title",
"series": "Comic Series",
"series_number": 4,
"authors": []string{"Comic Writer"},
"tags": []string{"Superhero"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "comicinfo", body["metadata_target"])
// ---- verify the FILE's ComicInfo.xml by parsing it ----
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var infoData []byte
for _, f := range reader.File {
if f.Name == "ComicInfo.xml" {
rc, err := f.Open()
require.NoError(t, err)
infoData, err = io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
}
}
reader.Close()
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
var info struct {
Title string `xml:"Title"`
Series string `xml:"Series"`
Number int `xml:"Number"`
Writer string `xml:"Writer"`
}
require.NoError(t, xml.Unmarshal(infoData, &info))
assert.Equal(t, "Comic New Title", info.Title)
assert.Equal(t, "Comic Series", info.Series)
assert.Equal(t, 4, info.Number)
assert.Equal(t, "Comic Writer", info.Writer)
// The inline rescan reads the ComicInfo back: the DB title and
// series stay the written values.
var title, series string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
assert.Equal(t, "Comic New Title", title)
assert.Equal(t, "Comic Series", series)
})
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
})
}