feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars

Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

Dispatch (services/metadata_writer.go): EPUB/KEPUB gets its embedded
OPF spliced in place (fresh dc:* block generated inside the ORIGINAL
metadata open tag; manifest/spine/guide untouched; every other zip
entry copied byte-identical in original order so mimetype stays first
and STORED; original dc:identifiers preserved — the package's
unique-identifier must keep resolving). Everything else takes a
Calibre-style sidecar: metadata.opf when the book is ALONE in its
directory (Calibre's own convention — round-trips through Calibre
too), <basename>.opf when siblings share the folder, so one book's
sidecar can never poison dir-mates' scans (the scanner now reads both,
basename first).

Safety contract: temp file + atomic rename — the original is never
truncated on failure; the generated OPF is unit-tested to ROUND-TRIP
through the scanner's own parser (write side and read side can never
disagree); metadata_overrides are deliberately kept (file and DB agree
after the write; overrides still guard against stale sidecars). The
endpoint re-extracts inline (RescanMediaItem) and reports rescan
failure separately — a failed rescan doesn't undo the write. SHA-256
changes are inherent and documented for users (Kobo/KOReader re-key).

UI: the metadata editor modal carries the file path as a data
attribute; the button confirms before mutating, labels the target
(embedded vs sidecar) and toasts the outcome. Integration tests pin
the 403, missing-file error, EPUB embed + post-write rescan title, the
lone-book metadata.opf case, and the shared-dir <basename>.opf case
with no dir-scoped litter.
This commit is contained in:
John O'Keefe committed 2026-10-27 15:36:43 -04:00
1 parent 61e5f47ec0
commit eaf1b2c8dd
13 files changed
+1591 -413

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
package main
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"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", "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, and the ORIGINAL
// unique-identifier survives the splice.
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
defer reader.Close()
var opfContent string
for _, f := range reader.File {
if f.Name == "OEBPS/content.opf" {
rc, err := f.Open()
require.NoError(t, err)
buf := new(strings.Builder)
_, err = io.Copy(buf, rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
opfContent = buf.String()
}
}
assert.Contains(t, opfContent, "Embedded New Title")
assert.Contains(t, opfContent, "Embedded Series")
assert.NotContains(t, opfContent, "Original EPUB Title")
assert.Contains(t, 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")
assert.Contains(t, string(data), "Sidecar New Title")
})
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.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
})
}