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:
1 parent
61e5f47ec0
commit
eaf1b2c8dd
13 files changed
+1591
-413
No files matched your search
@@ -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())))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user