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.
226 lines
9.7 KiB
Go
226 lines
9.7 KiB
Go
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())))
|
|
})
|
|
}
|